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:
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!(