cli

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

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:
Msrc/out/terminal/actions.rs | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/out/terminal/registry.rs | 19+++++++++++++++++--
Asrc/out/terminal/renderers/basket.rs | 405+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/market.rs | 216+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/out/terminal/renderers/mod.rs | 2++
Mtests/target_cli.rs | 2+-
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()); }