cli

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

commit 886f6e99494c339e83ce138c43735efc11f876fd
parent f114708403f2bfbb404a6f90b192a7b87eba8be1
Author: triesap <tyson@radroots.org>
Date:   Wed, 13 May 2026 03:25:22 +0000

cli: gate market checkout actions

- add typed market readiness fields for protocol, marketplace, and checkout state
- gate market buyer-intent actions on checkout-enabled listings only
- expose checkout-disabled reason codes in search and listing get output
- cover enabled and disabled checkout paths in nested CLI tests

Diffstat:
Msrc/domain/runtime.rs | 145+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_market.rs | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime/find.rs | 67++++++++++++++++++++++++++++++++++++++++++-------------------------
Msrc/runtime/listing.rs | 30++++++++++++++++++++++++------
Mtests/support/mod.rs | 16++++++++++++++++
Mtests/target_cli.rs | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
6 files changed, 398 insertions(+), 45 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -4,11 +4,13 @@ use std::process::ExitCode; use radroots_core::{RadrootsCoreCurrency, RadrootsCoreDecimal}; use radroots_events::farm::RadrootsFarm; +use radroots_events::kinds::KIND_LISTING; use radroots_events::listing::RadrootsListingLocation; use radroots_events::profile::RadrootsProfile; use radroots_events::trade::{ RadrootsTradeOrderEconomics, RadrootsTradePaymentMethod, RadrootsTradeSettlementDecision, }; +use radroots_events_codec::trade::RadrootsTradeListingAddress; use radroots_nostr_accounts::prelude::RadrootsNostrAccountRecord; use serde::Serialize; @@ -1052,6 +1054,145 @@ pub struct FindHyfView { } #[derive(Debug, Clone, Serialize)] +pub struct MarketReadinessView { + pub protocol_valid: bool, + pub marketplace_eligible: bool, + pub checkout_enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub reason_codes: Vec<String>, +} + +impl MarketReadinessView { + pub fn unavailable(reason_code: impl Into<String>) -> Self { + Self { + protocol_valid: false, + marketplace_eligible: false, + checkout_enabled: false, + reason_codes: vec![reason_code.into()], + } + } + + pub fn from_market_projection( + listing_addr: Option<&str>, + title: Option<&str>, + category: Option<&str>, + available_amount: Option<i64>, + price_amount: f64, + price_currency: &str, + price_per_amount: f64, + ) -> Self { + let protocol_valid = listing_addr.is_some_and(|listing_addr| { + RadrootsTradeListingAddress::parse(listing_addr) + .is_ok_and(|parsed| parsed.kind == KIND_LISTING) + }); + let marketplace_eligible = protocol_valid + && title.is_some_and(|title| !title.trim().is_empty()) + && category.is_some_and(|category| !category.trim().is_empty()); + let inventory_available = available_amount.is_some_and(|amount| amount > 0); + let price_available = price_amount.is_finite() + && price_amount > 0.0 + && !price_currency.trim().is_empty() + && price_per_amount.is_finite() + && price_per_amount > 0.0; + let checkout_enabled = marketplace_eligible && inventory_available && price_available; + let mut reason_codes = Vec::new(); + if !protocol_valid { + reason_codes.push("listing_protocol_invalid".to_owned()); + } + if protocol_valid && !marketplace_eligible { + reason_codes.push("listing_marketplace_ineligible".to_owned()); + } + if marketplace_eligible && !checkout_enabled { + reason_codes.push("listing_checkout_disabled".to_owned()); + if !inventory_available { + reason_codes.push("listing_inventory_unavailable".to_owned()); + } + if !price_available { + reason_codes.push("listing_price_unavailable".to_owned()); + } + } + Self { + protocol_valid, + marketplace_eligible, + checkout_enabled, + reason_codes, + } + } +} + +#[cfg(test)] +mod market_readiness_tests { + use super::MarketReadinessView; + + const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + + #[test] + fn market_readiness_separates_protocol_marketplace_and_checkout_state() { + let enabled = MarketReadinessView::from_market_projection( + Some(LISTING_ADDR), + Some("Eggs"), + Some("eggs"), + Some(1), + 6.0, + "USD", + 1.0, + ); + assert!(enabled.protocol_valid); + assert!(enabled.marketplace_eligible); + assert!(enabled.checkout_enabled); + assert!(enabled.reason_codes.is_empty()); + + let invalid = MarketReadinessView::from_market_projection( + None, + Some("Eggs"), + Some("eggs"), + Some(1), + 6.0, + "USD", + 1.0, + ); + assert!(!invalid.protocol_valid); + assert!(!invalid.marketplace_eligible); + assert!(!invalid.checkout_enabled); + assert_eq!(invalid.reason_codes, vec!["listing_protocol_invalid"]); + + let ineligible = MarketReadinessView::from_market_projection( + Some(LISTING_ADDR), + Some(" "), + Some("eggs"), + Some(1), + 6.0, + "USD", + 1.0, + ); + assert!(ineligible.protocol_valid); + assert!(!ineligible.marketplace_eligible); + assert!(!ineligible.checkout_enabled); + assert_eq!( + ineligible.reason_codes, + vec!["listing_marketplace_ineligible"] + ); + + let checkout_disabled = MarketReadinessView::from_market_projection( + Some(LISTING_ADDR), + Some("Eggs"), + Some("eggs"), + Some(0), + 6.0, + "USD", + 1.0, + ); + assert!(checkout_disabled.protocol_valid); + assert!(checkout_disabled.marketplace_eligible); + assert!(!checkout_disabled.checkout_enabled); + assert_eq!( + checkout_disabled.reason_codes, + vec!["listing_checkout_disabled", "listing_inventory_unavailable"] + ); + } +} + +#[derive(Debug, Clone, Serialize)] pub struct JobListView { pub state: String, pub source: String, @@ -2614,6 +2755,8 @@ pub struct ListingGetView { pub state: String, pub source: String, pub lookup: String, + #[serde(flatten)] + pub readiness: MarketReadinessView, #[serde(skip_serializing_if = "Option::is_none")] pub listing_id: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] @@ -2810,6 +2953,8 @@ pub struct ListingMutationEventView { pub struct FindResultView { pub id: String, pub product_key: String, + #[serde(flatten)] + pub readiness: MarketReadinessView, #[serde(skip_serializing_if = "Option::is_none")] pub listing_addr: Option<String>, pub title: String, diff --git a/src/operation_market.rs b/src/operation_market.rs @@ -1,5 +1,3 @@ -use radroots_events::kinds::KIND_LISTING; -use radroots_events_codec::trade::RadrootsTradeListingAddress; use serde::Serialize; use serde_json::Value; @@ -106,7 +104,7 @@ fn market_product_search_view(mut view: FindView) -> FindView { "radroots market listing get {}", result.product_key )]; - if listing_addr_can_back_basket(result.listing_addr.as_deref()) { + if result.readiness.checkout_enabled { actions.push("radroots basket create".to_owned()); } actions @@ -128,7 +126,7 @@ fn market_product_search_view(mut view: FindView) -> FindView { fn market_listing_get_view(mut view: ListingGetView) -> ListingGetView { view.actions = match view.state.as_str() { "ready" => { - if listing_addr_can_back_basket(view.listing_addr.as_deref()) { + if view.readiness.checkout_enabled { vec!["radroots basket create".to_owned()] } else { Vec::new() @@ -147,13 +145,6 @@ fn market_listing_get_view(mut view: ListingGetView) -> ListingGetView { view } -fn listing_addr_can_back_basket(listing_addr: Option<&str>) -> bool { - let Some(listing_addr) = listing_addr else { - return false; - }; - RadrootsTradeListingAddress::parse(listing_addr).is_ok_and(|parsed| parsed.kind == KIND_LISTING) -} - fn required_query_terms<P>( request: &OperationRequest<P>, ) -> Result<Vec<String>, OperationAdapterError> @@ -260,7 +251,7 @@ mod tests { use super::{MarketOperationService, market_listing_get_view, market_product_search_view}; use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView, - ListingGetView, SyncFreshnessView, + ListingGetView, MarketReadinessView, SyncFreshnessView, }; use crate::operation_adapter::{ MarketListingGetRequest, MarketProductSearchRequest, MarketRefreshRequest, @@ -447,6 +438,7 @@ mod tests { results: vec![FindResultView { id: "listing_eggs".to_owned(), product_key: "eggs".to_owned(), + readiness: readiness_enabled(), listing_addr: Some(LISTING_ADDR.to_owned()), title: "Eggs".to_owned(), category: "eggs".to_owned(), @@ -474,6 +466,7 @@ mod tests { state: "ready".to_owned(), source: "test".to_owned(), lookup: "eggs".to_owned(), + readiness: readiness_enabled(), listing_id: Some("listing_eggs".to_owned()), product_key: Some("eggs".to_owned()), listing_addr: Some(LISTING_ADDR.to_owned()), @@ -498,6 +491,62 @@ mod tests { ); } + #[test] + fn market_ready_actions_require_checkout_enabled() { + let disabled_search = market_product_search_view(FindView { + state: "ready".to_owned(), + source: "test".to_owned(), + query: "eggs".to_owned(), + count: 1, + relay_count: 1, + replica_db: "ready".to_owned(), + freshness: freshness(), + results: vec![FindResultView { + id: "listing_eggs".to_owned(), + product_key: "eggs".to_owned(), + readiness: readiness_disabled(), + listing_addr: Some(LISTING_ADDR.to_owned()), + title: "Eggs".to_owned(), + category: "eggs".to_owned(), + summary: None, + location_primary: None, + available: quantity(), + price: price(), + provenance: provenance(), + hyf: None, + }], + hyf: None, + reason: None, + actions: Vec::new(), + }); + + assert_eq!( + disabled_search.actions, + vec!["radroots market listing get eggs".to_owned()] + ); + + let disabled_listing = market_listing_get_view(ListingGetView { + state: "ready".to_owned(), + source: "test".to_owned(), + lookup: "eggs".to_owned(), + readiness: readiness_disabled(), + listing_id: Some("listing_eggs".to_owned()), + product_key: Some("eggs".to_owned()), + listing_addr: Some(LISTING_ADDR.to_owned()), + title: Some("Eggs".to_owned()), + category: Some("eggs".to_owned()), + description: None, + location_primary: None, + available: Some(quantity()), + price: Some(price()), + provenance: provenance(), + reason: None, + actions: Vec::new(), + }); + + assert!(disabled_listing.actions.is_empty()); + } + fn freshness() -> SyncFreshnessView { SyncFreshnessView { state: "fresh".to_owned(), @@ -534,6 +583,27 @@ mod tests { } } + fn readiness_enabled() -> MarketReadinessView { + MarketReadinessView { + protocol_valid: true, + marketplace_eligible: true, + checkout_enabled: true, + reason_codes: Vec::new(), + } + } + + fn readiness_disabled() -> MarketReadinessView { + MarketReadinessView { + protocol_valid: true, + marketplace_eligible: true, + checkout_enabled: false, + reason_codes: vec![ + "listing_checkout_disabled".to_owned(), + "listing_inventory_unavailable".to_owned(), + ], + } + } + fn sample_config(root: &Path) -> RuntimeConfig { let data = root.join("data"); let logs = root.join("logs"); diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -3,7 +3,7 @@ use radroots_sql_core::SqliteExecutor; use crate::domain::runtime::{ FindHyfView, FindPriceView, FindQuantityView, FindResultHyfView, FindResultProvenanceView, - FindResultView, FindView, + FindResultView, FindView, MarketReadinessView, }; use crate::runtime::RuntimeError; use crate::runtime::config::RuntimeConfig; @@ -79,30 +79,47 @@ pub fn search(config: &RuntimeConfig, args: &FindQueryArgs) -> Result<FindView, }; let results = rows .into_iter() - .map(|row| FindResultView { - id: row.id, - product_key: row.key, - listing_addr: row.listing_addr.and_then(non_empty), - title: row.title, - category: row.category, - summary: non_empty(row.summary), - location_primary: row.location_primary.and_then(non_empty), - available: FindQuantityView { - total_amount: row.qty_amt, - total_unit: row.qty_unit, - label: row.qty_label.and_then(non_empty), - available_amount: row.qty_avail, - }, - price: FindPriceView { - amount: row.price_amt, - currency: row.price_currency, - per_amount: row.price_qty_amt, - per_unit: row.price_qty_unit, - }, - provenance: result_provenance.clone(), - hyf: applied_query_rewrite - .as_ref() - .map(AppliedQueryRewrite::to_result_view), + .map(|row| { + let listing_addr = row.listing_addr.and_then(non_empty); + let available_amount = row.qty_avail; + let price_amount = row.price_amt; + let price_currency = row.price_currency; + let price_per_amount = row.price_qty_amt; + let readiness = MarketReadinessView::from_market_projection( + listing_addr.as_deref(), + Some(row.title.as_str()), + Some(row.category.as_str()), + available_amount, + price_amount, + price_currency.as_str(), + price_per_amount, + ); + FindResultView { + id: row.id, + product_key: row.key, + readiness, + listing_addr, + title: row.title, + category: row.category, + summary: non_empty(row.summary), + location_primary: row.location_primary.and_then(non_empty), + available: FindQuantityView { + total_amount: row.qty_amt, + total_unit: row.qty_unit, + label: row.qty_label.and_then(non_empty), + available_amount, + }, + price: FindPriceView { + amount: price_amount, + currency: price_currency, + per_amount: price_per_amount, + per_unit: row.price_qty_unit, + }, + provenance: result_provenance.clone(), + hyf: applied_query_rewrite + .as_ref() + .map(AppliedQueryRewrite::to_result_view), + } }) .collect::<Vec<_>>(); diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -33,7 +33,7 @@ use crate::domain::runtime::{ FindPriceView, FindQuantityView, FindResultProvenanceView, ListingGetView, ListingListView, ListingMutationEventView, ListingMutationLocalReplicaView, ListingMutationView, ListingNewView, ListingRebindView, ListingSummaryView, ListingValidateView, ListingValidationIssueView, - RelayFailureView, + MarketReadinessView, RelayFailureView, }; use crate::runtime::RuntimeError; use crate::runtime::accounts; @@ -943,6 +943,7 @@ pub fn get( state: "unconfigured".to_owned(), source: LISTING_READ_SOURCE.to_owned(), lookup: args.key.clone(), + readiness: MarketReadinessView::unavailable("local_replica_not_initialized"), listing_id: None, product_key: None, listing_addr: None, @@ -965,6 +966,7 @@ pub fn get( state: "missing".to_owned(), source: LISTING_READ_SOURCE.to_owned(), lookup: args.key.clone(), + readiness: MarketReadinessView::unavailable("market_listing_missing"), listing_id: None, product_key: None, listing_addr: None, @@ -986,13 +988,29 @@ pub fn get( }); }; + let listing_addr = row.listing_addr.and_then(non_empty); + let available_amount = row.qty_avail; + let price_amount = row.price_amt; + let price_currency = row.price_currency; + let price_per_amount = row.price_qty_amt; + let readiness = MarketReadinessView::from_market_projection( + listing_addr.as_deref(), + Some(row.title.as_str()), + Some(row.category.as_str()), + available_amount, + price_amount, + price_currency.as_str(), + price_per_amount, + ); + Ok(ListingGetView { state: "ready".to_owned(), source: LISTING_READ_SOURCE.to_owned(), lookup: args.key.clone(), + readiness, listing_id: Some(row.id), product_key: Some(row.key), - listing_addr: row.listing_addr.and_then(non_empty), + listing_addr, title: Some(row.title), category: Some(row.category), description: non_empty(row.summary), @@ -1001,12 +1019,12 @@ pub fn get( total_amount: row.qty_amt, total_unit: row.qty_unit, label: row.qty_label.and_then(non_empty), - available_amount: row.qty_avail, + available_amount, }), price: Some(FindPriceView { - amount: row.price_amt, - currency: row.price_currency, - per_amount: row.price_qty_amt, + amount: price_amount, + currency: price_currency, + per_amount: price_per_amount, per_unit: row.price_qty_unit, }), provenance, diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -287,6 +287,22 @@ pub fn remove_orderable_listing(sandbox: &RadrootsCliSandbox, listing_addr: &str .expect("delete listing row"); } +pub fn update_orderable_listing_available_amount( + sandbox: &RadrootsCliSandbox, + listing_addr: &str, + available_amount: i64, +) { + let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica db"); + let params = serde_json::to_string(&serde_json::json!([available_amount, listing_addr])) + .expect("update listing params"); + executor + .exec( + "UPDATE trade_product SET qty_avail = ? WHERE listing_addr = ?;", + params.as_str(), + ) + .expect("update listing available amount"); +} + pub fn replace_latest_listing_event_id( sandbox: &RadrootsCliSandbox, listing_addr: &str, diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -28,8 +28,8 @@ use support::{ assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret, json_from_stdout, make_listing_publishable, make_listing_publishable_with_seller, ndjson_from_stdout, radroots, remove_orderable_listing, replace_latest_listing_event_id, - seed_orderable_listing, toml_string, write_public_identity_profile, - write_secret_identity_profile, + seed_orderable_listing, toml_string, update_orderable_listing_available_amount, + write_public_identity_profile, write_secret_identity_profile, }; const LISTING_ADDR: &str = @@ -3443,6 +3443,93 @@ fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { } #[test] +fn market_checkout_readiness_gates_buyer_intent_actions() { + let sandbox = RadrootsCliSandbox::new(); + seed_orderable_listing(&sandbox, LISTING_ADDR); + + let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); + assert_eq!(search["operation_id"], "market.product.search"); + let result = &search["result"]["results"][0]; + assert_eq!(result["protocol_valid"], true); + assert_eq!(result["marketplace_eligible"], true); + assert_eq!(result["checkout_enabled"], true); + assert!(result.get("reason_codes").is_none()); + assert!( + search["result"]["actions"] + .as_array() + .expect("search actions") + .iter() + .any(|action| action == "radroots basket create") + ); + + let listing = sandbox.json_success(&[ + "--format", + "json", + "market", + "listing", + "get", + "pasture-eggs", + ]); + assert_eq!(listing["operation_id"], "market.listing.get"); + assert_eq!(listing["result"]["protocol_valid"], true); + assert_eq!(listing["result"]["marketplace_eligible"], true); + assert_eq!(listing["result"]["checkout_enabled"], true); + assert!( + listing["result"]["actions"] + .as_array() + .expect("listing actions") + .iter() + .any(|action| action == "radroots basket create") + ); + + update_orderable_listing_available_amount(&sandbox, LISTING_ADDR, 0); + + let disabled_search = + sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); + let disabled_result = &disabled_search["result"]["results"][0]; + assert_eq!(disabled_result["protocol_valid"], true); + assert_eq!(disabled_result["marketplace_eligible"], true); + assert_eq!(disabled_result["checkout_enabled"], false); + assert_eq!( + disabled_result["reason_codes"][0], + "listing_checkout_disabled" + ); + assert_eq!( + disabled_result["reason_codes"][1], + "listing_inventory_unavailable" + ); + assert!( + disabled_search["result"]["actions"] + .as_array() + .expect("disabled search actions") + .iter() + .all(|action| action != "radroots basket create") + ); + + let disabled_listing = sandbox.json_success(&[ + "--format", + "json", + "market", + "listing", + "get", + "pasture-eggs", + ]); + assert_eq!(disabled_listing["result"]["protocol_valid"], true); + assert_eq!(disabled_listing["result"]["marketplace_eligible"], true); + assert_eq!(disabled_listing["result"]["checkout_enabled"], false); + assert_eq!( + disabled_listing["result"]["reason_codes"][0], + "listing_checkout_disabled" + ); + assert!( + disabled_listing["result"] + .get("actions") + .and_then(Value::as_array) + .is_none_or(Vec::is_empty) + ); +} + +#[test] fn required_approval_token_rejects_absent_empty_and_whitespace_values() { let sandbox = RadrootsCliSandbox::new(); let public_identity = identity_public(61);