commit e472c3ce6aacbd82ac5f67abbc2ec1703bc80123
parent a2c677b2bb41cd36a18944a6004fa65f2b4629c4
Author: triesap <tyson@radroots.org>
Date: Sat, 27 Jun 2026 02:49:27 +0000
terminal: add buyer renderers
- register market and basket terminal renderers for buyer workflows
- render market refresh, search, listing, and basket views
- placeholder incomplete buyer next actions with required inputs
- prove buyer terminal output without changing machine formats
Diffstat:
6 files changed, 826 insertions(+), 20 deletions(-)
diff --git a/src/out/terminal/actions.rs b/src/out/terminal/actions.rs
@@ -90,34 +90,178 @@ pub fn terminal_action_from_command(command: &str) -> TerminalAction {
fn command_requires_placeholder(command: &str) -> bool {
matches!(
command,
- "radroots account attach-secret"
- | "radroots basket item add"
- | "radroots basket item update"
- | "radroots basket adjustment add"
- | "radroots basket adjustment remove"
- | "radroots basket quote create"
- | "radroots listing publish"
- )
+ "radroots account attach-secret" | "radroots listing publish"
+ ) || basket_item_add_requires_placeholder(command)
+ || basket_item_update_requires_placeholder(command)
+ || basket_adjustment_add_requires_placeholder(command)
+ || basket_adjustment_remove_requires_placeholder(command)
+ || basket_quote_create_requires_placeholder(command)
}
fn command_with_placeholder(command: &str) -> String {
+ if basket_item_add_requires_placeholder(command) {
+ return basket_item_add_placeholder(command);
+ }
+ if basket_item_update_requires_placeholder(command) {
+ return basket_item_update_placeholder(command);
+ }
+ if basket_adjustment_add_requires_placeholder(command) {
+ return basket_adjustment_add_placeholder(command);
+ }
+ if basket_adjustment_remove_requires_placeholder(command) {
+ return basket_adjustment_remove_placeholder(command);
+ }
+ if basket_quote_create_requires_placeholder(command) {
+ return basket_quote_create_placeholder(command);
+ }
match command {
- "radroots account attach-secret" => "radroots account attach-secret <account> <identity.json>",
- "radroots basket item add" => {
- "radroots basket item add <basket> --listing <product> --bin-id <bin>"
+ "radroots account attach-secret" => {
+ "radroots account attach-secret <account> <identity.json>"
}
- "radroots basket item update" => "radroots basket item update <basket> --item-id <item>",
- "radroots basket adjustment add" => {
- "radroots basket adjustment add <basket> --id <id> --effect <effect> --amount <amount> --currency <currency> --reason <reason>"
- }
- "radroots basket adjustment remove" => "radroots basket adjustment remove <basket> --id <id>",
- "radroots basket quote create" => "radroots basket quote create <basket>",
"radroots listing publish" => "radroots listing publish <file>",
other => other,
}
.to_owned()
}
+fn basket_item_add_requires_placeholder(command: &str) -> bool {
+ command_has_prefix(command, &["radroots", "basket", "item", "add"])
+ && (!has_basket_arg(command, 4)
+ || (!has_flag(command, "--listing") && !has_flag(command, "--listing-addr"))
+ || !has_flag(command, "--bin-id"))
+}
+
+fn basket_item_update_requires_placeholder(command: &str) -> bool {
+ command_has_prefix(command, &["radroots", "basket", "item", "update"])
+ && (!has_basket_arg(command, 4)
+ || !has_flag(command, "--item-id")
+ || !has_any_flag(
+ command,
+ &["--listing", "--listing-addr", "--bin-id", "--quantity"],
+ ))
+}
+
+fn basket_adjustment_add_requires_placeholder(command: &str) -> bool {
+ command_has_prefix(command, &["radroots", "basket", "adjustment", "add"])
+ && (!has_basket_arg(command, 4)
+ || !has_flag(command, "--id")
+ || !has_flag(command, "--effect")
+ || !has_flag(command, "--amount")
+ || !has_flag(command, "--currency")
+ || !has_flag(command, "--reason"))
+}
+
+fn basket_adjustment_remove_requires_placeholder(command: &str) -> bool {
+ command_has_prefix(command, &["radroots", "basket", "adjustment", "remove"])
+ && (!has_basket_arg(command, 4) || !has_flag(command, "--id"))
+}
+
+fn basket_quote_create_requires_placeholder(command: &str) -> bool {
+ command_has_prefix(command, &["radroots", "basket", "quote", "create"])
+ && !has_basket_arg(command, 4)
+}
+
+fn basket_item_add_placeholder(command: &str) -> String {
+ let basket = basket_arg(command, 4);
+ let listing = flag_value(command, "--listing")
+ .map(|value| format!("--listing {value}"))
+ .or_else(|| {
+ flag_value(command, "--listing-addr").map(|value| format!("--listing-addr {value}"))
+ })
+ .unwrap_or_else(|| "--listing <product>".to_owned());
+ let bin = flag_value(command, "--bin-id").unwrap_or_else(|| "<bin>".to_owned());
+ let quantity = flag_value(command, "--quantity")
+ .map(|value| format!(" --quantity {value}"))
+ .unwrap_or_default();
+ format!("radroots basket item add {basket} {listing} --bin-id {bin}{quantity}")
+}
+
+fn basket_item_update_placeholder(command: &str) -> String {
+ let basket = basket_arg(command, 4);
+ let item = flag_value(command, "--item-id").unwrap_or_else(|| "<item>".to_owned());
+ let update = if let Some(value) = flag_value(command, "--listing") {
+ format!("--listing {value}")
+ } else if let Some(value) = flag_value(command, "--listing-addr") {
+ format!("--listing-addr {value}")
+ } else if let Some(value) = flag_value(command, "--bin-id") {
+ format!("--bin-id {value}")
+ } else if let Some(value) = flag_value(command, "--quantity") {
+ format!("--quantity {value}")
+ } else {
+ "--quantity <quantity>".to_owned()
+ };
+ format!("radroots basket item update {basket} --item-id {item} {update}")
+}
+
+fn basket_adjustment_add_placeholder(command: &str) -> String {
+ let basket = basket_arg(command, 4);
+ let id = flag_value(command, "--id").unwrap_or_else(|| "<id>".to_owned());
+ let effect = flag_value(command, "--effect").unwrap_or_else(|| "<effect>".to_owned());
+ let amount = flag_value(command, "--amount").unwrap_or_else(|| "<amount>".to_owned());
+ let currency = flag_value(command, "--currency").unwrap_or_else(|| "<currency>".to_owned());
+ let reason = flag_value(command, "--reason").unwrap_or_else(|| "<reason>".to_owned());
+ format!(
+ "radroots basket adjustment add {basket} --id {id} --effect {effect} --amount {amount} --currency {currency} --reason {reason}"
+ )
+}
+
+fn basket_adjustment_remove_placeholder(command: &str) -> String {
+ let basket = basket_arg(command, 4);
+ let id = flag_value(command, "--id").unwrap_or_else(|| "<id>".to_owned());
+ format!("radroots basket adjustment remove {basket} --id {id}")
+}
+
+fn basket_quote_create_placeholder(command: &str) -> String {
+ let basket = basket_arg(command, 4);
+ format!("radroots basket quote create {basket}")
+}
+
+fn command_has_prefix(command: &str, prefix: &[&str]) -> bool {
+ let parts = command.split_whitespace().collect::<Vec<_>>();
+ parts.len() >= prefix.len()
+ && parts
+ .iter()
+ .zip(prefix.iter())
+ .all(|(part, prefix)| part == prefix)
+}
+
+fn has_basket_arg(command: &str, index: usize) -> bool {
+ command
+ .split_whitespace()
+ .nth(index)
+ .is_some_and(|value| !value.starts_with("--"))
+}
+
+fn basket_arg(command: &str, index: usize) -> String {
+ command
+ .split_whitespace()
+ .nth(index)
+ .filter(|value| !value.starts_with("--"))
+ .unwrap_or("<basket>")
+ .to_owned()
+}
+
+fn has_any_flag(command: &str, flags: &[&str]) -> bool {
+ flags.iter().any(|flag| has_flag(command, flag))
+}
+
+fn has_flag(command: &str, flag: &str) -> bool {
+ flag_value(command, flag).is_some()
+}
+
+fn flag_value(command: &str, flag: &str) -> Option<String> {
+ let mut parts = command.split_whitespace();
+ while let Some(part) = parts.next() {
+ if part == flag {
+ return parts
+ .next()
+ .filter(|value| !value.starts_with("--"))
+ .map(str::to_owned);
+ }
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -143,6 +287,30 @@ mod tests {
}
#[test]
+ fn partially_bound_basket_item_add_is_placeholdered() {
+ let action = terminal_action_from_command("radroots basket item add basket_test");
+
+ assert_eq!(action.kind, TerminalActionKind::Placeholder);
+ assert_eq!(
+ action.command.as_deref(),
+ Some("radroots basket item add basket_test --listing <product> --bin-id <bin>")
+ );
+ }
+
+ #[test]
+ fn complete_basket_item_add_renders_as_command() {
+ let action = terminal_action_from_command(
+ "radroots basket item add basket_test --listing eggs --bin-id bin-1",
+ );
+
+ assert_eq!(action.kind, TerminalActionKind::Command);
+ assert_eq!(
+ action.command.as_deref(),
+ Some("radroots basket item add basket_test --listing eggs --bin-id bin-1")
+ );
+ }
+
+ #[test]
fn converts_operator_config_action_to_setup() {
let action = NextAction {
kind: NextActionKind::OperatorConfig,
diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs
@@ -66,7 +66,9 @@ pub fn terminal_renderer_registry() -> TerminalRendererRegistry {
let registry = crate::out::terminal::renderers::store::register(registry);
let registry = crate::out::terminal::renderers::sync::register(registry);
let registry = crate::out::terminal::renderers::farm::register(registry);
- crate::out::terminal::renderers::listing::register(registry)
+ let registry = crate::out::terminal::renderers::listing::register(registry);
+ let registry = crate::out::terminal::renderers::market::register(registry);
+ crate::out::terminal::renderers::basket::register(registry)
}
#[cfg(test)]
@@ -102,7 +104,7 @@ mod tests {
}
#[test]
- fn registry_covers_core_and_seller_operations() {
+ fn registry_covers_core_seller_and_buyer_operations() {
let registry = terminal_renderer_registry();
let expected = [
"workspace.init",
@@ -150,6 +152,19 @@ mod tests {
"listing.rebind",
"listing.publish",
"listing.archive",
+ "market.refresh",
+ "market.product.search",
+ "market.listing.get",
+ "basket.create",
+ "basket.get",
+ "basket.list",
+ "basket.item.add",
+ "basket.item.update",
+ "basket.item.remove",
+ "basket.adjustment.add",
+ "basket.adjustment.remove",
+ "basket.validate",
+ "basket.quote.create",
];
assert_eq!(registry.len(), expected.len());
diff --git a/src/out/terminal/renderers/basket.rs b/src/out/terminal/renderers/basket.rs
@@ -0,0 +1,405 @@
+use serde_json::Value;
+
+use crate::out::envelope::OutputEnvelope;
+use crate::out::terminal::layout::TerminalDocument;
+use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry};
+use crate::out::terminal::renderer::TerminalRenderContext;
+use crate::out::terminal::tables::{TerminalTableColumn, TerminalTableRow};
+
+use super::common;
+
+pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry {
+ registry
+ .register("basket.create", &BASKET_RENDERER)
+ .register("basket.get", &BASKET_RENDERER)
+ .register("basket.list", &BASKET_RENDERER)
+ .register("basket.item.add", &BASKET_RENDERER)
+ .register("basket.item.update", &BASKET_RENDERER)
+ .register("basket.item.remove", &BASKET_RENDERER)
+ .register("basket.adjustment.add", &BASKET_RENDERER)
+ .register("basket.adjustment.remove", &BASKET_RENDERER)
+ .register("basket.validate", &BASKET_RENDERER)
+ .register("basket.quote.create", &BASKET_RENDERER)
+}
+
+struct BasketRenderer;
+
+static BASKET_RENDERER: BasketRenderer = BasketRenderer;
+
+impl TerminalOperationRenderer for BasketRenderer {
+ fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument {
+ if !envelope.errors.is_empty() {
+ return common::generic_terminal_document(envelope);
+ }
+ let result = common::display_source(envelope);
+ match envelope.operation_id.as_str() {
+ "basket.list" => basket_list_document(envelope, result),
+ "basket.validate" => basket_validate_document(envelope, result),
+ "basket.quote.create" => basket_quote_document(envelope, result),
+ "basket.item.add" | "basket.item.update" | "basket.item.remove" => {
+ basket_mutation_document(envelope, result)
+ }
+ "basket.adjustment.add" | "basket.adjustment.remove" => {
+ basket_mutation_document(envelope, result)
+ }
+ _ => basket_detail_document(envelope, result),
+ }
+ }
+}
+
+fn basket_detail_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document =
+ common::document_with_title(envelope, basket_title(envelope.operation_id.as_str()));
+ push_basket_fields(&mut document, result);
+ push_items_section(&mut document, result);
+ push_adjustments_section(&mut document, result);
+ push_issues_section(&mut document, result);
+ document
+}
+
+fn basket_mutation_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document =
+ common::document_with_title(envelope, basket_title(envelope.operation_id.as_str()));
+ push_basket_fields(&mut document, result);
+ push_single_item_fields(&mut document, result);
+ push_single_adjustment_fields(&mut document, result);
+ push_items_section(&mut document, result);
+ push_adjustments_section(&mut document, result);
+ push_issues_section(&mut document, result);
+ document
+}
+
+fn basket_validate_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_title(envelope, "Basket validation");
+ push_basket_fields(&mut document, result);
+ push_issues_section(&mut document, result);
+ document
+}
+
+fn basket_quote_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_title(envelope, "Basket quote");
+ push_basket_fields(&mut document, result);
+ common::push_path_field(&mut document, "Quote", result, &["quote", "quote_id"]);
+ common::push_path_field(&mut document, "Trade", result, &["quote", "trade_id"]);
+ common::push_path_field(&mut document, "Trade", result, &["trade", "trade_id"]);
+ common::push_bool_field(
+ &mut document,
+ "Submit ready",
+ result,
+ &["quote", "ready_for_submit"],
+ );
+ push_single_item_fields(&mut document, result);
+ push_issues_section(&mut document, result);
+ push_trade_issues_section(&mut document, result);
+ document
+}
+
+fn basket_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_title(envelope, "Baskets");
+ common::push_path_field(&mut document, "State", result, &["state"]);
+ common::push_count_field(&mut document, "Count", result, &["count"]);
+ let rows = common::array(result, &["baskets"])
+ .into_iter()
+ .flatten()
+ .map(|basket| {
+ TerminalTableRow::new(vec![
+ common::string(basket, &["basket_id"]).unwrap_or_default(),
+ common::string(basket, &["state"]).unwrap_or_default(),
+ common::number_label_path(basket, &["item_count"]).unwrap_or_default(),
+ common::bool_path(basket, &["ready_for_quote"])
+ .map(|ready| if ready { "yes" } else { "no" }.to_owned())
+ .unwrap_or_default(),
+ ])
+ })
+ .collect::<Vec<_>>();
+ document.sections.push(common::table_section(
+ "Baskets",
+ vec![
+ TerminalTableColumn::new("Basket", 6, 24),
+ TerminalTableColumn::new("State", 5, 14),
+ TerminalTableColumn::new("Items", 5, 5),
+ TerminalTableColumn::new("Quote", 5, 5),
+ ],
+ rows,
+ "No baskets found",
+ ));
+ document
+}
+
+fn push_basket_fields(document: &mut TerminalDocument, result: &Value) {
+ common::push_path_field(document, "State", result, &["state"]);
+ common::push_path_field(document, "Basket", result, &["basket_id"]);
+ common::push_path_field(document, "Lookup", result, &["lookup"]);
+ common::push_count_field(document, "Items", result, &["item_count"]);
+ common::push_count_field(document, "Adjustments", result, &["adjustment_count"]);
+ common::push_bool_field(document, "Quote ready", result, &["ready_for_quote"]);
+ common::push_path_field(document, "File", result, &["file"]);
+ common::push_path_field(document, "Reason", result, &["reason"]);
+}
+
+fn push_single_item_fields(document: &mut TerminalDocument, result: &Value) {
+ let Some(item) = result.get("item") else {
+ return;
+ };
+ common::push_path_field(document, "Item", item, &["item_id"]);
+ common::push_path_field(document, "Listing", item, &["listing"]);
+ common::push_path_field(document, "Listing", item, &["listing_addr"]);
+ common::push_path_field(document, "Bin", item, &["bin_id"]);
+ common::push_count_field(document, "Quantity", item, &["quantity"]);
+}
+
+fn push_single_adjustment_fields(document: &mut TerminalDocument, result: &Value) {
+ let Some(adjustment) = result.get("adjustment") else {
+ return;
+ };
+ common::push_path_field(document, "Adjustment", adjustment, &["id"]);
+ common::push_path_field(document, "Effect", adjustment, &["effect"]);
+ common::push_path_field(document, "Amount", adjustment, &["amount"]);
+ common::push_path_field(document, "Currency", adjustment, &["currency"]);
+ common::push_path_field(document, "Reason", adjustment, &["reason"]);
+}
+
+fn push_items_section(document: &mut TerminalDocument, result: &Value) {
+ let rows = common::array(result, &["items"])
+ .into_iter()
+ .flatten()
+ .map(|item| {
+ TerminalTableRow::new(vec![
+ common::string(item, &["item_id"]).unwrap_or_default(),
+ common::string(item, &["listing"])
+ .or_else(|| common::string(item, &["listing_addr"]))
+ .unwrap_or_default(),
+ common::string(item, &["bin_id"]).unwrap_or_default(),
+ common::number_label_path(item, &["quantity"]).unwrap_or_default(),
+ ])
+ })
+ .collect::<Vec<_>>();
+ if rows.is_empty() {
+ return;
+ }
+ document.sections.push(common::table_section(
+ "Items",
+ vec![
+ TerminalTableColumn::new("Item", 4, 12),
+ TerminalTableColumn::new("Listing", 8, 34),
+ TerminalTableColumn::new("Bin", 3, 10),
+ TerminalTableColumn::new("Qty", 3, 5),
+ ],
+ rows,
+ "No basket items",
+ ));
+}
+
+fn push_adjustments_section(document: &mut TerminalDocument, result: &Value) {
+ let rows = common::array(result, &["adjustments"])
+ .into_iter()
+ .flatten()
+ .map(|adjustment| {
+ TerminalTableRow::new(vec![
+ common::string(adjustment, &["id"]).unwrap_or_default(),
+ common::string(adjustment, &["effect"]).unwrap_or_default(),
+ common::string(adjustment, &["amount"]).unwrap_or_default(),
+ common::string(adjustment, &["reason"]).unwrap_or_default(),
+ ])
+ })
+ .collect::<Vec<_>>();
+ if rows.is_empty() {
+ return;
+ }
+ document.sections.push(common::table_section(
+ "Adjustments",
+ vec![
+ TerminalTableColumn::new("ID", 2, 14),
+ TerminalTableColumn::new("Effect", 6, 9),
+ TerminalTableColumn::new("Amount", 6, 10),
+ TerminalTableColumn::new("Reason", 6, 24),
+ ],
+ rows,
+ "No basket adjustments",
+ ));
+}
+
+fn push_issues_section(document: &mut TerminalDocument, result: &Value) {
+ let rows = issue_rows(result, &["issues"]);
+ if rows.is_empty() {
+ return;
+ }
+ document.sections.push(common::table_section(
+ "Issues",
+ vec![
+ TerminalTableColumn::new("Code", 8, 32),
+ TerminalTableColumn::new("Field", 8, 24),
+ TerminalTableColumn::new("Message", 7, 48),
+ ],
+ rows,
+ "No basket issues",
+ ));
+}
+
+fn push_trade_issues_section(document: &mut TerminalDocument, result: &Value) {
+ let rows = issue_rows(result, &["trade", "issues"]);
+ if rows.is_empty() {
+ return;
+ }
+ document.sections.push(common::table_section(
+ "Trade issues",
+ vec![
+ TerminalTableColumn::new("Code", 8, 32),
+ TerminalTableColumn::new("Field", 8, 24),
+ TerminalTableColumn::new("Message", 7, 48),
+ ],
+ rows,
+ "No trade issues",
+ ));
+}
+
+fn issue_rows(result: &Value, path: &[&str]) -> Vec<TerminalTableRow> {
+ common::array(result, path)
+ .into_iter()
+ .flatten()
+ .map(|issue| {
+ TerminalTableRow::new(vec![
+ common::string(issue, &["code"]).unwrap_or_default(),
+ common::string(issue, &["field"]).unwrap_or_default(),
+ common::string(issue, &["message"]).unwrap_or_default(),
+ ])
+ })
+ .collect()
+}
+
+fn basket_title(operation_id: &str) -> &'static str {
+ match operation_id {
+ "basket.create" => "Basket created",
+ "basket.get" => "Basket",
+ "basket.item.add" => "Basket item added",
+ "basket.item.update" => "Basket item updated",
+ "basket.item.remove" => "Basket item removed",
+ "basket.adjustment.add" => "Basket adjustment added",
+ "basket.adjustment.remove" => "Basket adjustment removed",
+ _ => "Basket",
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use crate::out::envelope::{EnvelopeContext, next_actions_from_result_value};
+ use crate::out::terminal::renderer::render_terminal_document;
+
+ use super::*;
+
+ fn success_envelope(operation_id: &str, result: Value, dry_run: bool) -> OutputEnvelope {
+ let mut envelope = OutputEnvelope::success(
+ operation_id,
+ result,
+ EnvelopeContext::new(format!("req_{operation_id}"), dry_run),
+ );
+ envelope.next_actions = next_actions_from_result_value(&envelope.result);
+ envelope
+ }
+
+ #[test]
+ fn empty_list_renders_stable_empty_table() {
+ let envelope = success_envelope(
+ "basket.list",
+ json!({
+ "state": "empty",
+ "count": 0,
+ "baskets": [],
+ "actions": ["radroots basket create"]
+ }),
+ false,
+ );
+ let document = BASKET_RENDERER.render(&envelope, &TerminalRenderContext::default());
+ let rendered = render_terminal_document(&document, &TerminalRenderContext::default());
+
+ assert!(rendered.contains("Baskets"));
+ assert!(rendered.contains("No baskets found"));
+ assert!(rendered.contains("radroots basket create"));
+ }
+
+ #[test]
+ fn validation_issues_render_as_issue_table() {
+ let envelope = success_envelope(
+ "basket.validate",
+ json!({
+ "state": "unconfigured",
+ "basket_id": "basket_test",
+ "ready_for_quote": false,
+ "item_count": 1,
+ "adjustment_count": 0,
+ "issues": [{
+ "code": "basket_market_replica_missing",
+ "field": "local.replica_db",
+ "message": "current local replica data is required before quote creation"
+ }],
+ "actions": ["radroots basket get basket_test"]
+ }),
+ false,
+ );
+ let document = BASKET_RENDERER.render(&envelope, &TerminalRenderContext::default());
+ let rendered = render_terminal_document(&document, &TerminalRenderContext::default());
+
+ assert!(rendered.contains("Issues"));
+ assert!(rendered.contains("basket_market_replica_missing"));
+ assert!(rendered.contains("local.replica_db"));
+ }
+
+ #[test]
+ fn incomplete_buyer_actions_are_placeholdered() {
+ let envelope = success_envelope(
+ "basket.item.add",
+ json!({
+ "state": "dry_run",
+ "basket_id": "basket_test",
+ "item": {
+ "item_id": "item_1",
+ "listing": "eggs",
+ "bin_id": "bin-1",
+ "quantity": 1
+ },
+ "actions": ["radroots basket item add"]
+ }),
+ true,
+ );
+ let document = BASKET_RENDERER.render(&envelope, &TerminalRenderContext::default());
+ let rendered = render_terminal_document(&document, &TerminalRenderContext::default());
+
+ assert!(
+ rendered
+ .contains("radroots basket item add <basket> --listing <product> --bin-id <bin>")
+ );
+ }
+
+ #[test]
+ fn quote_next_action_uses_trade_surface() {
+ let envelope = success_envelope(
+ "basket.quote.create",
+ json!({
+ "state": "quoted",
+ "basket_id": "basket_test",
+ "quote": {
+ "quote_id": "quote_test",
+ "trade_id": "trade_test",
+ "trade_file": "internal-draft-path",
+ "ready_for_submit": true
+ },
+ "trade": {
+ "trade_id": "trade_test",
+ "file": "internal-draft-path",
+ "ready_for_submit": true
+ },
+ "actions": ["radroots trade submit trade_test"]
+ }),
+ false,
+ );
+ let document = BASKET_RENDERER.render(&envelope, &TerminalRenderContext::default());
+ let rendered = render_terminal_document(&document, &TerminalRenderContext::default());
+
+ assert!(rendered.contains("Trade"));
+ assert!(rendered.contains("radroots trade submit trade_test"));
+ assert!(!rendered.contains("order"));
+ assert!(!rendered.contains("internal-draft-path"));
+ }
+}
diff --git a/src/out/terminal/renderers/market.rs b/src/out/terminal/renderers/market.rs
@@ -0,0 +1,216 @@
+use serde_json::Value;
+
+use crate::out::envelope::OutputEnvelope;
+use crate::out::terminal::layout::{TerminalDocument, TerminalSection};
+use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry};
+use crate::out::terminal::renderer::TerminalRenderContext;
+use crate::out::terminal::tables::{TerminalTableColumn, TerminalTableRow};
+use crate::out::terminal::values::{price_label, quantity_label};
+
+use super::common;
+
+pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry {
+ registry
+ .register("market.refresh", &MARKET_RENDERER)
+ .register("market.product.search", &MARKET_RENDERER)
+ .register("market.listing.get", &MARKET_RENDERER)
+}
+
+struct MarketRenderer;
+
+static MARKET_RENDERER: MarketRenderer = MarketRenderer;
+
+impl TerminalOperationRenderer for MarketRenderer {
+ fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument {
+ if !envelope.errors.is_empty() {
+ return common::generic_terminal_document(envelope);
+ }
+ let result = common::display_source(envelope);
+ match envelope.operation_id.as_str() {
+ "market.product.search" => market_search_document(envelope, result),
+ "market.listing.get" => market_listing_document(envelope, result),
+ _ => market_refresh_document(envelope, result),
+ }
+ }
+}
+
+fn market_refresh_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_status_title(envelope, "Market refresh");
+ common::push_path_field(&mut document, "State", result, &["state"]);
+ common::push_path_field(
+ &mut document,
+ "Freshness",
+ result,
+ &["freshness", "display"],
+ );
+ common::push_count_field(&mut document, "Relays", result, &["relay_count"]);
+ common::push_count_field(&mut document, "Fetched", result, &["fetched_count"]);
+ common::push_count_field(&mut document, "Ingested", result, &["ingested_count"]);
+ common::push_count_field(&mut document, "Skipped", result, &["skipped_count"]);
+ common::push_count_field(&mut document, "Failed", result, &["failed_count"]);
+ common::push_path_field(&mut document, "Replica", result, &["replica_db"]);
+ common::push_path_field(&mut document, "Reason", result, &["reason"]);
+ document
+}
+
+fn market_search_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_title(envelope, "Market search");
+ common::push_path_field(&mut document, "State", result, &["state"]);
+ common::push_path_field(&mut document, "Query", result, &["query"]);
+ common::push_count_field(&mut document, "Count", result, &["count"]);
+ common::push_path_field(
+ &mut document,
+ "Freshness",
+ result,
+ &["freshness", "display"],
+ );
+ common::push_count_field(&mut document, "Relays", result, &["relay_count"]);
+ let rows = common::array(result, &["results"])
+ .into_iter()
+ .flatten()
+ .map(|entry| {
+ TerminalTableRow::new(vec![
+ common::string(entry, &["product_key"])
+ .or_else(|| common::string(entry, &["id"]))
+ .unwrap_or_default(),
+ common::string(entry, &["title"]).unwrap_or_default(),
+ quantity(entry, &["available"]),
+ price(entry, &["price"]),
+ checkout_label(entry),
+ ])
+ })
+ .collect::<Vec<_>>();
+ document.sections.push(common::table_section(
+ "Results",
+ vec![
+ TerminalTableColumn::new("Listing", 8, 18),
+ TerminalTableColumn::new("Title", 5, 22),
+ TerminalTableColumn::new("Available", 9, 16),
+ TerminalTableColumn::new("Price", 5, 18),
+ TerminalTableColumn::new("Checkout", 8, 8),
+ ],
+ rows,
+ "No matching market listings",
+ ));
+ if let Some(hyf) = result.get("hyf") {
+ common::push_verbose_path_field(&mut document, "HYF", hyf, &["state"]);
+ common::push_verbose_path_field(&mut document, "Rewritten", hyf, &["rewritten_query"]);
+ }
+ document
+}
+
+fn market_listing_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument {
+ let mut document = common::document_with_title(envelope, "Market listing");
+ common::push_path_field(&mut document, "State", result, &["state"]);
+ common::push_path_field(&mut document, "Listing", result, &["listing_id"]);
+ common::push_path_field(&mut document, "Key", result, &["product_key"]);
+ common::push_path_field(&mut document, "Title", result, &["title"]);
+ common::push_path_field(&mut document, "Category", result, &["category"]);
+ common::push_path_field(&mut document, "Location", result, &["location_primary"]);
+ common::push_path_field(&mut document, "Primary bin", result, &["primary_bin_id"]);
+ common::push_field(&mut document, "Available", quantity(result, &["available"]));
+ common::push_field(&mut document, "Price", price(result, &["price"]));
+ common::push_field(&mut document, "Checkout", checkout_label(result));
+ common::push_path_field(
+ &mut document,
+ "Freshness",
+ result,
+ &["provenance", "freshness"],
+ );
+ push_reason_codes(&mut document, result);
+ document
+}
+
+fn push_reason_codes(document: &mut TerminalDocument, result: &Value) {
+ let lines = common::array(result, &["reason_codes"])
+ .into_iter()
+ .flatten()
+ .filter_map(Value::as_str)
+ .map(str::to_owned)
+ .collect::<Vec<_>>();
+ if !lines.is_empty() {
+ document
+ .sections
+ .push(TerminalSection::lines("Reasons", lines));
+ }
+}
+
+fn quantity(value: &Value, path: &[&str]) -> String {
+ let Some(quantity) = nested(value, path) else {
+ return String::new();
+ };
+ common::string(quantity, &["label"]).unwrap_or_else(|| {
+ let amount = common::number_label_path(quantity, &["total_amount"])
+ .or_else(|| common::number_label_path(quantity, &["available_amount"]))
+ .unwrap_or_default();
+ let unit = common::string(quantity, &["total_unit"]);
+ quantity_label(amount, unit.as_deref())
+ })
+}
+
+fn price(value: &Value, path: &[&str]) -> String {
+ let Some(price) = nested(value, path) else {
+ return String::new();
+ };
+ let amount = common::number_label_path(price, &["amount"]).unwrap_or_default();
+ let currency = common::string(price, &["currency"]).unwrap_or_default();
+ let unit = common::string(price, &["per_unit"]);
+ if currency.is_empty() {
+ return amount;
+ }
+ price_label(amount, currency.as_str(), unit.as_deref())
+}
+
+fn checkout_label(value: &Value) -> String {
+ common::bool_path(value, &["checkout_enabled"])
+ .map(|enabled| {
+ if enabled {
+ "ready".to_owned()
+ } else {
+ "blocked".to_owned()
+ }
+ })
+ .unwrap_or_default()
+}
+
+fn nested<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> {
+ let mut current = value;
+ for segment in path {
+ current = current.get(*segment)?;
+ }
+ Some(current)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use crate::out::envelope::{EnvelopeContext, next_actions_from_result_value};
+ use crate::out::terminal::renderer::render_terminal_document;
+
+ use super::*;
+
+ #[test]
+ fn empty_search_renders_stable_empty_table() {
+ let mut envelope = OutputEnvelope::success(
+ "market.product.search",
+ json!({
+ "state": "empty",
+ "query": "eggs",
+ "count": 0,
+ "relay_count": 0,
+ "freshness": { "display": "missing" },
+ "results": [],
+ "actions": ["radroots market refresh"]
+ }),
+ EnvelopeContext::new("req_market_search", false),
+ );
+ envelope.next_actions = next_actions_from_result_value(&envelope.result);
+ let document = MARKET_RENDERER.render(&envelope, &TerminalRenderContext::default());
+ let rendered = render_terminal_document(&document, &TerminalRenderContext::default());
+
+ assert!(rendered.contains("Market search"));
+ assert!(rendered.contains("No matching market listings"));
+ assert!(rendered.contains("radroots market refresh"));
+ }
+}
diff --git a/src/out/terminal/renderers/mod.rs b/src/out/terminal/renderers/mod.rs
@@ -1,9 +1,11 @@
pub mod account;
+pub mod basket;
pub mod common;
pub mod config;
pub mod farm;
pub mod health;
pub mod listing;
+pub mod market;
pub mod runtime;
pub mod store;
pub mod sync;
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -2900,7 +2900,7 @@ fn terminal_market_refresh_missing_store_shows_action() {
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
assert!(stdout.starts_with("! Market refresh unconfigured\n"));
- assert!(stdout.contains("Reason local replica database is not initialized"));
+ assert!(stdout.contains("local replica database is not initialized"));
assert!(stdout.contains("Next\n radroots store init"));
assert!(serde_json::from_str::<Value>(&stdout).is_err());
}