cli

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

commit 3f6650d85331170574479ecd20abb650f6d7ba1f
parent ae95e9c5c397c6a68e7d19c555cfece6ab80a93f
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 04:49:02 +0000

market: gate checkout actions on listing address

- carry listing_addr through find listing and market views
- emit checkout guidance only for address-backed listings
- keep addressless market rows in discovery output
- cover machine output and human action rendering

Diffstat:
Msrc/commands/market.rs | 28+++++++++++++++++++++++-----
Msrc/domain/runtime.rs | 4++++
Msrc/render/mod.rs | 17+++++++++++++++++
Msrc/runtime/find.rs | 1+
Msrc/runtime/listing.rs | 3+++
Mtests/find.rs | 19++++++++++++++++++-
Mtests/listing.rs | 9++++++++-
Mtests/market.rs | 19++++++++++++++++++-
8 files changed, 92 insertions(+), 8 deletions(-)

diff --git a/src/commands/market.rs b/src/commands/market.rs @@ -1,3 +1,6 @@ +use radroots_events::kinds::KIND_LISTING; +use radroots_events_codec::trade::RadrootsTradeListingAddress; + use crate::cli::{FindArgs, RecordKeyArgs}; use crate::domain::runtime::{ CommandDisposition, CommandOutput, CommandView, FindView, ListingGetView, SyncActionView, @@ -52,10 +55,14 @@ fn market_search_view(mut view: FindView) -> FindView { .results .first() .map(|result| { - vec![ - format!("radroots market view {}", result.product_key), - format!("radroots order create --listing {}", result.product_key), - ] + let mut actions = vec![format!("radroots market view {}", result.product_key)]; + if listing_addr_can_back_order(result.listing_addr.as_deref()) { + actions.push(format!( + "radroots order create --listing {}", + result.product_key + )); + } + actions }) .unwrap_or_default(), "empty" => vec![ @@ -75,7 +82,11 @@ fn market_view_view(mut view: ListingGetView) -> ListingGetView { .as_deref() .unwrap_or(view.lookup.as_str()) .to_owned(); - vec![format!("radroots order create --listing {listing_key}")] + if listing_addr_can_back_order(view.listing_addr.as_deref()) { + vec![format!("radroots order create --listing {listing_key}")] + } else { + Vec::new() + } } "missing" => vec![ "radroots market search tomatoes".to_owned(), @@ -108,6 +119,13 @@ fn market_update_output(view: SyncActionView) -> CommandOutput { } } +fn listing_addr_can_back_order(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 market_search_output(view: FindView) -> CommandOutput { match view.disposition() { CommandDisposition::Success => CommandOutput::success(CommandView::MarketSearch(view)), diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -1702,6 +1702,8 @@ pub struct ListingGetView { #[serde(skip_serializing_if = "Option::is_none")] pub product_key: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, + #[serde(skip_serializing_if = "Option::is_none")] pub title: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub category: Option<String>, @@ -1811,6 +1813,8 @@ pub struct ListingMutationEventView { pub struct FindResultView { pub id: String, pub product_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub listing_addr: Option<String>, pub title: String, pub category: String, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/render/mod.rs b/src/render/mod.rs @@ -1692,6 +1692,15 @@ fn render_market_search_card( let mut rows = vec![("Key", result.product_key.clone())]; push_row( &mut rows, + "Listing address", + result + .listing_addr + .as_deref() + .and_then(non_empty_str) + .map(str::to_owned), + ); + push_row( + &mut rows, "Place", result .location_primary @@ -2641,6 +2650,14 @@ fn render_market_view(stdout: &mut dyn Write, view: &ListingGetView) -> Result<( ); push_row( &mut rows, + "Listing address", + view.listing_addr + .as_deref() + .and_then(non_empty_str) + .map(str::to_owned), + ); + push_row( + &mut rows, "Category", view.category .as_deref() diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -82,6 +82,7 @@ pub fn search(config: &RuntimeConfig, args: &FindArgs) -> Result<FindView, Runti .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), diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -960,6 +960,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<ListingGetVie lookup: args.key.clone(), listing_id: None, product_key: None, + listing_addr: None, title: None, category: None, description: None, @@ -981,6 +982,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<ListingGetVie lookup: args.key.clone(), listing_id: None, product_key: None, + listing_addr: None, title: None, category: None, description: None, @@ -1005,6 +1007,7 @@ pub fn get(config: &RuntimeConfig, args: &RecordKeyArgs) -> Result<ListingGetVie lookup: args.key.clone(), listing_id: Some(row.id), product_key: Some(row.key), + listing_addr: row.listing_addr.and_then(non_empty), title: Some(row.title), category: Some(row.category), description: non_empty(row.summary), diff --git a/tests/find.rs b/tests/find.rs @@ -6,6 +6,9 @@ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +const ADDRESS_BACKED_LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + fn data_root(workdir: &Path) -> std::path::PathBuf { if cfg!(windows) { workdir.join("local").join("Radroots").join("data") @@ -77,6 +80,7 @@ fn find_returns_json_and_ndjson_from_local_market_rows() { dir.path(), "00000000-0000-0000-0000-000000000101", "heirloom-tomato", + Some(ADDRESS_BACKED_LISTING_ADDR), "produce", "Heirloom Tomato", "Bright red slicing tomatoes", @@ -88,6 +92,7 @@ fn find_returns_json_and_ndjson_from_local_market_rows() { dir.path(), "00000000-0000-0000-0000-000000000102", "tomato-sauce", + None, "prepared", "Tomato Sauce", "Slow cooked tomato sauce", @@ -109,6 +114,10 @@ fn find_returns_json_and_ndjson_from_local_market_rows() { "local_replica.trade_product" ); assert_eq!(json["results"][0]["location_primary"], "Asheville"); + assert_eq!( + json["results"][0]["listing_addr"], + ADDRESS_BACKED_LISTING_ADDR + ); let ndjson_output = cli_command_in(dir.path()) .args(["--ndjson", "find", "tomato"]) @@ -119,6 +128,7 @@ fn find_returns_json_and_ndjson_from_local_market_rows() { let lines = stdout.lines().collect::<Vec<_>>(); assert_eq!(lines.len(), 2); assert!(lines[0].contains("\"title\":\"Heirloom Tomato\"")); + assert!(lines[0].contains("\"listing_addr\"")); assert!(lines[1].contains("\"title\":\"Tomato Sauce\"")); } @@ -135,6 +145,7 @@ fn find_human_output_uses_market_cards_without_internal_footer() { dir.path(), "00000000-0000-0000-0000-000000000103", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -196,6 +207,7 @@ fn find_uses_hyf_query_rewrite_when_available() { dir.path(), "00000000-0000-0000-0000-000000000104", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -266,6 +278,7 @@ fn find_human_output_tiers_change_information_budget() { dir.path(), "00000000-0000-0000-0000-000000000105", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -331,6 +344,7 @@ fn find_uses_hyf_query_rewrite_without_status_preflight() { dir.path(), "00000000-0000-0000-0000-000000000106", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -373,6 +387,7 @@ fn find_falls_back_cleanly_when_hyf_is_unavailable() { dir.path(), "00000000-0000-0000-0000-000000000105", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -400,6 +415,7 @@ fn seed_trade_product( workdir: &Path, product_id: &str, key: &str, + listing_addr: Option<&str>, category: &str, title: &str, summary: &str, @@ -412,12 +428,13 @@ fn seed_trade_product( let now = "2026-04-07T00:00:00.000Z"; executor .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", json!([ product_id, now, now, key, + listing_addr, category, title, summary, diff --git a/tests/listing.rs b/tests/listing.rs @@ -14,6 +14,9 @@ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +const ADDRESS_BACKED_LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + fn data_root(workdir: &Path) -> std::path::PathBuf { if cfg!(windows) { workdir.join("local").join("Radroots").join("data") @@ -368,6 +371,7 @@ fn listing_get_reads_real_local_rows_and_reports_missing() { dir.path(), "00000000-0000-0000-0000-000000000301", "pasture-eggs", + Some(ADDRESS_BACKED_LISTING_ADDR), "protein", "Pasture Eggs", "Fresh pasture-raised eggs collected daily.", @@ -384,6 +388,7 @@ fn listing_get_reads_real_local_rows_and_reports_missing() { let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); assert_eq!(json["state"], "ready"); assert_eq!(json["product_key"], "pasture-eggs"); + assert_eq!(json["listing_addr"], ADDRESS_BACKED_LISTING_ADDR); assert_eq!(json["title"], "Pasture Eggs"); assert_eq!(json["location_primary"], "Marshall"); assert_eq!(json["provenance"]["origin"], "local_replica.trade_product"); @@ -1722,6 +1727,7 @@ fn seed_trade_product( workdir: &Path, product_id: &str, key: &str, + listing_addr: Option<&str>, category: &str, title: &str, summary: &str, @@ -1734,12 +1740,13 @@ fn seed_trade_product( let now = "2026-04-07T00:00:00.000Z"; executor .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", json!([ product_id, now, now, key, + listing_addr, category, title, summary, diff --git a/tests/market.rs b/tests/market.rs @@ -7,6 +7,9 @@ use radroots_sql_core::{SqlExecutor, SqliteExecutor}; use serde_json::{Value, json}; use tempfile::tempdir; +const ADDRESS_BACKED_LISTING_ADDR: &str = + "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; + fn data_root(workdir: &Path) -> std::path::PathBuf { if cfg!(windows) { workdir.join("local").join("Radroots").join("data") @@ -55,6 +58,7 @@ fn seed_trade_product( workdir: &Path, product_id: &str, key: &str, + listing_addr: Option<&str>, category: &str, title: &str, summary: &str, @@ -67,12 +71,13 @@ fn seed_trade_product( let now = "2026-04-07T00:00:00.000Z"; executor .exec( - "INSERT INTO trade_product (id, created_at, updated_at, key, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", + "INSERT INTO trade_product (id, created_at, updated_at, key, listing_addr, category, title, summary, process, lot, profile, year, qty_amt, qty_unit, qty_label, qty_avail, price_amt, price_currency, price_qty_amt, price_qty_unit, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);", json!([ product_id, now, now, key, + listing_addr, category, title, summary, @@ -236,6 +241,7 @@ fn market_search_preserves_machine_shape_and_renders_card_list() { dir.path(), "00000000-0000-0000-0000-000000000401", "sf-tomatoes", + Some(ADDRESS_BACKED_LISTING_ADDR), "produce", "San Francisco Early Girl Tomatoes", "Fresh local tomatoes packed for pickup from the farm.", @@ -254,6 +260,10 @@ fn market_search_preserves_machine_shape_and_renders_card_list() { assert_eq!(json["count"], 1); assert_eq!(json["results"][0]["product_key"], "sf-tomatoes"); assert_eq!( + json["results"][0]["listing_addr"], + ADDRESS_BACKED_LISTING_ADDR + ); + assert_eq!( json["results"][0]["title"], "San Francisco Early Girl Tomatoes" ); @@ -313,6 +323,7 @@ fn market_search_uses_also_searched_for_when_hyf_rewrites_query() { dir.path(), "00000000-0000-0000-0000-000000000402", "fresh-eggs", + None, "protein", "Fresh Eggs", "Pasture-raised eggs", @@ -338,6 +349,8 @@ fn market_search_uses_also_searched_for_when_hyf_rewrites_query() { assert_eq!(json["state"], "ready"); assert_eq!(json["hyf"]["state"], "query_rewrite_applied"); assert_eq!(json["hyf"]["rewritten_query"], "eggs"); + assert_eq!(json["actions"][0], "radroots market view fresh-eggs"); + assert_eq!(json["actions"].as_array().expect("actions").len(), 1); let human_output = cli_command_in(dir.path()) .env("RADROOTS_HYF_ENABLED", "true") @@ -350,6 +363,8 @@ fn market_search_uses_also_searched_for_when_hyf_rewrites_query() { assert!(stdout.contains("1 listing for eggs")); assert!(stdout.contains("Also searched for")); assert!(stdout.contains("henhouse")); + assert!(stdout.contains("radroots market view fresh-eggs")); + assert!(!stdout.contains("radroots order create --listing fresh-eggs")); assert!(!stdout.contains("hyf: query rewritten")); } @@ -366,6 +381,7 @@ fn market_view_wraps_listing_reads_and_guides_to_order_create() { dir.path(), "00000000-0000-0000-0000-000000000403", "pasture-eggs", + Some(ADDRESS_BACKED_LISTING_ADDR), "protein", "Pasture Eggs", "Fresh pasture-raised eggs collected daily.", @@ -382,6 +398,7 @@ fn market_view_wraps_listing_reads_and_guides_to_order_create() { let json: Value = serde_json::from_slice(json_output.stdout.as_slice()).expect("json"); assert_eq!(json["state"], "ready"); assert_eq!(json["product_key"], "pasture-eggs"); + assert_eq!(json["listing_addr"], ADDRESS_BACKED_LISTING_ADDR); assert_eq!(json["title"], "Pasture Eggs"); assert_eq!(json["location_primary"], "Marshall"); assert_eq!(