cli

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

commit a2c677b2bb41cd36a18944a6004fa65f2b4629c4
parent 2d190c4e00885cd30953b2646b47d5c4e502cf5b
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 02:36:20 +0000

terminal: add seller renderers

- register farm and listing terminal renderers for seller workflows
- render farm setup, location, readiness, and publish receipts from source fields
- render listing tables, validation output, app export, and publish receipts
- source-gate listing publish proof output and keep machine formats unchanged

Diffstat:
Msrc/out/terminal/registry.rs | 26++++++++++++++++++++++++--
Msrc/out/terminal/renderers/common.rs | 45+++++++++++++++++++++++++++++++++++++++++++--
Asrc/out/terminal/renderers/farm.rs | 368+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/listing.rs | 382+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/out/terminal/renderers/mod.rs | 2++
5 files changed, 819 insertions(+), 4 deletions(-)

diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -64,7 +64,9 @@ pub fn terminal_renderer_registry() -> TerminalRendererRegistry { let registry = crate::out::terminal::renderers::account::register(registry); let registry = crate::out::terminal::renderers::runtime::register(registry); let registry = crate::out::terminal::renderers::store::register(registry); - crate::out::terminal::renderers::sync::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) } #[cfg(test)] @@ -100,7 +102,7 @@ mod tests { } #[test] - fn core_runtime_registry_covers_rcld_04_operations() { + fn registry_covers_core_and_seller_operations() { let registry = terminal_renderer_registry(); let expected = [ "workspace.init", @@ -128,6 +130,26 @@ mod tests { "sync.pull", "sync.push", "sync.watch", + "farm.create", + "farm.get", + "farm.rebind", + "farm.profile.update", + "farm.location.set", + "farm.location.get", + "farm.location.clear", + "farm.fulfillment.update", + "farm.readiness.check", + "farm.publish", + "listing.create", + "listing.get", + "listing.list", + "listing.app.list", + "listing.app.export", + "listing.update", + "listing.validate", + "listing.rebind", + "listing.publish", + "listing.archive", ]; assert_eq!(registry.len(), expected.len()); diff --git a/src/out/terminal/renderers/common.rs b/src/out/terminal/renderers/common.rs @@ -132,8 +132,19 @@ pub(crate) fn push_count_field( value: &Value, path: &[&str], ) { - if let Some(value) = number_path(value, path) { - push_field(document, label, value.to_string()); + if let Some(value) = number_label_path(value, path) { + push_field(document, label, value); + } +} + +pub(crate) fn push_number_field( + document: &mut TerminalDocument, + label: &str, + value: &Value, + path: &[&str], +) { + if let Some(value) = number_label_path(value, path) { + push_field(document, label, value); } } @@ -181,6 +192,36 @@ pub(crate) fn number_path(value: &Value, path: &[&str]) -> Option<i64> { current.as_i64() } +pub(crate) fn number_label_path(value: &Value, path: &[&str]) -> Option<String> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + number_label(current) +} + +pub(crate) fn number_label(value: &Value) -> Option<String> { + if let Some(value) = value.as_i64() { + return Some(value.to_string()); + } + if let Some(value) = value.as_u64() { + return Some(value.to_string()); + } + value + .as_f64() + .filter(|value| value.is_finite()) + .map(|value| { + let mut rendered = format!("{value:.6}"); + while rendered.contains('.') && rendered.ends_with('0') { + rendered.pop(); + } + if rendered.ends_with('.') { + rendered.pop(); + } + rendered + }) +} + pub(crate) fn bool_path(value: &Value, path: &[&str]) -> Option<bool> { let mut current = value; for segment in path { diff --git a/src/out/terminal/renderers/farm.rs b/src/out/terminal/renderers/farm.rs @@ -0,0 +1,368 @@ +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 crate::out::terminal::values::{relay_summary, transport_label}; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("farm.create", &FARM_RENDERER) + .register("farm.get", &FARM_RENDERER) + .register("farm.rebind", &FARM_RENDERER) + .register("farm.profile.update", &FARM_RENDERER) + .register("farm.location.set", &FARM_RENDERER) + .register("farm.location.get", &FARM_RENDERER) + .register("farm.location.clear", &FARM_RENDERER) + .register("farm.fulfillment.update", &FARM_RENDERER) + .register("farm.readiness.check", &FARM_RENDERER) + .register("farm.publish", &FARM_RENDERER) +} + +struct FarmRenderer; + +static FARM_RENDERER: FarmRenderer = FarmRenderer; + +impl TerminalOperationRenderer for FarmRenderer { + 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() { + "farm.location.set" | "farm.location.get" | "farm.location.clear" => { + farm_location_document(envelope, result) + } + "farm.readiness.check" => farm_readiness_document(envelope, result), + "farm.publish" => farm_publish_document(envelope, result), + "farm.get" => farm_get_document(envelope, result), + "farm.rebind" => farm_rebind_document(envelope, result), + "farm.profile.update" | "farm.fulfillment.update" => { + farm_update_document(envelope, result) + } + _ => farm_create_document(envelope, result), + } + } +} + +fn farm_create_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = + common::document_with_title(envelope, farm_title(envelope.operation_id.as_str())); + push_farm_summary_fields(&mut document, result); + document +} + +fn farm_get_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Farm"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Scope", result, &["scope"]); + common::push_path_field(&mut document, "Path", result, &["path"]); + push_farm_document_fields(&mut document, result); + document +} + +fn farm_rebind_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Farm rebound"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Scope", result, &["scope"]); + common::push_path_field(&mut document, "Path", result, &["path"]); + common::push_path_field(&mut document, "From", result, &["from_seller_account_id"]); + common::push_path_field(&mut document, "To", result, &["to_seller_account_id"]); + common::push_bool_field( + &mut document, + "Seller changed", + result, + &["seller_pubkey_changed"], + ); + push_farm_summary_fields(&mut document, result); + document +} + +fn farm_update_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = + common::document_with_title(envelope, farm_title(envelope.operation_id.as_str())); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Field", result, &["field"]); + common::push_path_field(&mut document, "Value", result, &["value"]); + push_farm_summary_fields(&mut document, result); + document +} + +fn farm_location_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = + common::document_with_title(envelope, farm_title(envelope.operation_id.as_str())); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Farm", result, &["farm_d_tag"]); + common::push_path_field(&mut document, "Label", result, &["label"]); + common::push_number_field( + &mut document, + "Latitude", + result, + &["exact_location", "lat"], + ); + common::push_number_field( + &mut document, + "Longitude", + result, + &["exact_location", "lng"], + ); + common::push_path_field( + &mut document, + "Locality", + result, + &["public_locality", "primary"], + ); + common::push_path_field(&mut document, "City", result, &["public_locality", "city"]); + common::push_path_field( + &mut document, + "Region", + result, + &["public_locality", "region"], + ); + common::push_path_field( + &mut document, + "Country", + result, + &["public_locality", "country"], + ); + common::push_count_field(&mut document, "GeoNames", result, &["geonames_feature_id"]); + common::push_bool_field(&mut document, "Cleared", result, &["cleared"]); + common::push_verbose_path_field( + &mut document, + "GeoNames DB", + result, + &["geonames_database_path"], + ); + push_location_candidates(&mut document, result); + document +} + +fn farm_readiness_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = + common::document_with_title(envelope, common::title_for(envelope, "Farm readiness")); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Scope", result, &["scope"]); + common::push_path_field(&mut document, "Account", result, &["account_state"]); + common::push_path_field( + &mut document, + "Listings", + result, + &["listing_defaults_state"], + ); + common::push_path_field(&mut document, "Publish", result, &["publish_state"]); + if let Some(mode) = common::string(result, &["publish_transport"]) { + common::push_field(&mut document, "Transport", transport_label(mode.as_str())); + } + common::push_bool_field(&mut document, "Executable", result, &["publish_executable"]); + push_farm_summary_fields(&mut document, result); + push_missing_section(&mut document, result); + document +} + +fn farm_publish_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Farm published"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Farm", result, &["farm_d_tag"]); + common::push_path_field(&mut document, "Account", result, &["seller_account_id"]); + common::push_path_field(&mut document, "Profile", result, &["profile", "state"]); + common::push_path_field(&mut document, "Farm publish", result, &["farm", "state"]); + push_publish_component_fields( + &mut document, + "Profile relays", + "Profile event", + result, + &["profile"], + ); + push_publish_component_fields( + &mut document, + "Farm relays", + "Farm event", + result, + &["farm"], + ); + push_publish_components_section(&mut document, result); + document +} + +fn push_farm_summary_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field(document, "State", result, &["state"]); + common::push_path_field(document, "Source", result, &["source"]); + common::push_path_field(document, "Scope", result, &["config", "scope"]); + common::push_path_field(document, "Farm", result, &["config", "farm_d_tag"]); + common::push_path_field(document, "Name", result, &["config", "name"]); + common::push_path_field( + document, + "Location", + result, + &["config", "location_primary"], + ); + common::push_path_field(document, "Delivery", result, &["config", "delivery_method"]); + common::push_path_field(document, "Path", result, &["config", "path"]); +} + +fn push_farm_document_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field( + document, + "Farm", + result, + &["document", "selection", "farm_d_tag"], + ); + common::push_path_field( + document, + "Account", + result, + &["document", "selection", "seller_account_id"], + ); + common::push_path_field(document, "Name", result, &["document", "farm", "name"]); + common::push_path_field( + document, + "Display", + result, + &["document", "profile", "display_name"], + ); + common::push_path_field( + document, + "Location", + result, + &["document", "listing_defaults", "location", "primary"], + ); + common::push_path_field( + document, + "Delivery", + result, + &["document", "listing_defaults", "delivery_method"], + ); +} + +fn push_location_candidates(document: &mut TerminalDocument, result: &Value) { + let rows = common::array(result, &["candidates"]) + .into_iter() + .flatten() + .map(|candidate| { + TerminalTableRow::new(vec![ + common::number_label_path(candidate, &["geonames_feature_id"]).unwrap_or_default(), + common::string(candidate, &["display_name"]).unwrap_or_default(), + common::number_label_path(candidate, &["exact_location", "lat"]) + .unwrap_or_default(), + common::number_label_path(candidate, &["exact_location", "lng"]) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "GeoNames candidates", + vec![ + TerminalTableColumn::new("ID", 2, 10), + TerminalTableColumn::new("Name", 8, 32), + TerminalTableColumn::new("Lat", 3, 10), + TerminalTableColumn::new("Lng", 3, 11), + ], + rows, + "No GeoNames candidates", + )); +} + +fn push_missing_section(document: &mut TerminalDocument, result: &Value) { + let rows = common::array(result, &["missing"]) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(|value| TerminalTableRow::new(vec![value.to_owned()])) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "Missing", + vec![TerminalTableColumn::new("Item", 8, 48)], + rows, + "No missing requirements", + )); +} + +fn push_publish_component_fields( + document: &mut TerminalDocument, + relay_label: &str, + event_label: &str, + result: &Value, + path: &[&str], +) { + let Some(component) = nested_value(result, path) else { + return; + }; + if let Some(summary) = relay_component_summary(component) { + common::push_field(document, relay_label, summary); + } + common::push_path_field(document, event_label, component, &["event_id"]); + common::push_path_field(document, "Job", component, &["job_id"]); +} + +fn push_publish_components_section(document: &mut TerminalDocument, result: &Value) { + let rows = ["profile", "farm"] + .into_iter() + .filter_map(|component_name| { + let component = result.get(component_name)?; + Some(TerminalTableRow::new(vec![ + component_name.to_owned(), + common::string(component, &["state"]).unwrap_or_default(), + relay_component_summary(component).unwrap_or_default(), + ])) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "Publish components", + vec![ + TerminalTableColumn::new("Part", 4, 8), + TerminalTableColumn::new("State", 5, 16), + TerminalTableColumn::new("Relays", 8, 28), + ], + rows, + "No publish components", + )); +} + +fn relay_component_summary(value: &Value) -> Option<String> { + let acknowledged = common::array(value, &["acknowledged_relays"]) + .map(Vec::len) + .unwrap_or(0); + let failed = common::array(value, &["failed_relays"]) + .map(Vec::len) + .unwrap_or(0); + let target = common::array(value, &["target_relays"]) + .map(Vec::len) + .unwrap_or(0); + (acknowledged > 0 || failed > 0 || target > 0) + .then(|| relay_summary(acknowledged, failed, "acknowledged")) +} + +fn nested_value<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for segment in path { + current = current.get(*segment)?; + } + Some(current) +} + +fn farm_title(operation_id: &str) -> &'static str { + match operation_id { + "farm.create" => "Farm created", + "farm.rebind" => "Farm rebound", + "farm.profile.update" => "Farm profile updated", + "farm.location.set" => "Farm location set", + "farm.location.get" => "Farm location", + "farm.location.clear" => "Farm location cleared", + "farm.fulfillment.update" => "Farm fulfillment updated", + "farm.publish" => "Farm published", + _ => "Farm", + } +} diff --git a/src/out/terminal/renderers/listing.rs b/src/out/terminal/renderers/listing.rs @@ -0,0 +1,382 @@ +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 crate::out::terminal::values::{proof_summary, relay_summary, transport_label}; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("listing.create", &LISTING_RENDERER) + .register("listing.get", &LISTING_RENDERER) + .register("listing.list", &LISTING_RENDERER) + .register("listing.app.list", &LISTING_RENDERER) + .register("listing.app.export", &LISTING_RENDERER) + .register("listing.update", &LISTING_RENDERER) + .register("listing.validate", &LISTING_RENDERER) + .register("listing.rebind", &LISTING_RENDERER) + .register("listing.publish", &LISTING_RENDERER) + .register("listing.archive", &LISTING_RENDERER) +} + +struct ListingRenderer; + +static LISTING_RENDERER: ListingRenderer = ListingRenderer; + +impl TerminalOperationRenderer for ListingRenderer { + 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() { + "listing.list" => listing_list_document(envelope, result), + "listing.app.list" => listing_app_list_document(envelope, result), + "listing.app.export" => listing_app_export_document(envelope, result), + "listing.validate" => listing_validate_document(envelope, result), + "listing.rebind" => listing_rebind_document(envelope, result), + "listing.publish" => listing_publish_document(envelope, result), + "listing.archive" | "listing.update" => listing_mutation_document(envelope, result), + "listing.get" => listing_get_document(envelope, result), + _ => listing_create_document(envelope, result), + } + } +} + +fn listing_create_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listing draft created"); + 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, &["key"]); + common::push_path_field(&mut document, "Farm", result, &["farm_d_tag"]); + common::push_path_field(&mut document, "Delivery", result, &["delivery_method"]); + common::push_path_field(&mut document, "Location", result, &["location_primary"]); + common::push_path_field(&mut document, "File", result, &["file"]); + document +} + +fn listing_get_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "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, "Available", result, &["available", "label"]); + common::push_path_field(&mut document, "Currency", result, &["price", "currency"]); + common::push_number_field(&mut document, "Price", result, &["price", "amount"]); + document +} + +fn listing_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listings"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + common::push_verbose_path_field(&mut document, "Drafts", result, &["draft_dir"]); + let rows = common::array(result, &["listings"]) + .into_iter() + .flatten() + .map(|listing| { + TerminalTableRow::new(vec![ + common::string(listing, &["id"]).unwrap_or_default(), + common::string(listing, &["title"]) + .or_else(|| common::string(listing, &["product_key"])) + .unwrap_or_default(), + common::string(listing, &["state"]).unwrap_or_default(), + common::string(listing, &["file"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Listings", + vec![ + TerminalTableColumn::new("Listing", 8, 22), + TerminalTableColumn::new("Title", 5, 24), + TerminalTableColumn::new("State", 5, 12), + TerminalTableColumn::new("File", 4, 34), + ], + rows, + "No listing drafts found", + )); + document +} + +fn listing_app_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listing app records"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + common::push_bool_field(&mut document, "More", result, &["has_more"]); + let rows = common::array(result, &["records"]) + .into_iter() + .flatten() + .map(|record| { + TerminalTableRow::new(vec![ + common::string(record, &["record_id"]).unwrap_or_default(), + common::string(record, &["title"]) + .or_else(|| common::string(record, &["listing_id"])) + .unwrap_or_default(), + common::string(record, &["status"]).unwrap_or_default(), + common::bool_path(record, &["exportable"]) + .map(|value| if value { "yes" } else { "no" }.to_owned()) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Records", + vec![ + TerminalTableColumn::new("Record", 8, 22), + TerminalTableColumn::new("Listing", 8, 24), + TerminalTableColumn::new("Status", 6, 14), + TerminalTableColumn::new("Export", 6, 6), + ], + rows, + "No app-authored listing records found", + )); + document +} + +fn listing_app_export_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listing app export"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_path_field(&mut document, "Record", result, &["record_id"]); + common::push_path_field(&mut document, "Listing", result, &["listing_id"]); + common::push_path_field(&mut document, "Farm", result, &["farm_d_tag"]); + common::push_bool_field(&mut document, "Valid", result, &["valid"]); + common::push_path_field(&mut document, "File", result, &["file"]); + push_issues_section(&mut document, result); + document +} + +fn listing_validate_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let title = if common::bool_path(result, &["valid"]) == Some(true) { + "Listing valid" + } else { + "Listing validation" + }; + let mut document = common::document_with_title(envelope, title); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_bool_field(&mut document, "Valid", result, &["valid"]); + common::push_path_field(&mut document, "Listing", result, &["listing_id"]); + common::push_path_field(&mut document, "Farm", result, &["farm_d_tag"]); + common::push_path_field(&mut document, "File", result, &["file"]); + push_issues_section(&mut document, result); + document +} + +fn listing_rebind_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listing rebound"); + 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, "From", result, &["from_seller_account_id"]); + common::push_path_field(&mut document, "To", result, &["to_seller_account_id"]); + common::push_path_field(&mut document, "Farm", result, &["to_farm_d_tag"]); + common::push_bool_field( + &mut document, + "Seller changed", + result, + &["seller_pubkey_changed"], + ); + common::push_bool_field( + &mut document, + "Address changed", + result, + &["listing_addr_changed"], + ); + common::push_path_field(&mut document, "File", result, &["file"]); + document +} + +fn listing_mutation_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = + common::document_with_title(envelope, listing_title(envelope.operation_id.as_str())); + 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, "Address", result, &["listing_addr"]); + common::push_path_field(&mut document, "Account", result, &["seller_account_id"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "File", result, &["file"]); + push_relay_field(&mut document, result); + document +} + +fn listing_publish_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Listing published"); + if let Some(listing) = first_string( + result, + &[ + &["listing_id"], + &["listing", "id"], + &["listing", "d_tag"], + &["resource", "id"], + &["listing_addr"], + &["listing_address"], + ], + ) { + common::push_field(&mut document, "Listing", listing); + } + if let Some(mode) = first_string( + result, + &[ + &["publish", "mode"], + &["publish_transport"], + &["transport"], + &["source"], + ], + ) { + common::push_field(&mut document, "Transport", transport_label(mode.as_str())); + } + push_relay_field(&mut document, result); + if let Some(event_id) = first_string( + result, + &[ + &["event_id"], + &["publish", "event_id"], + &["event", "id"], + &["event", "event_id"], + &["receipt", "event_id"], + ], + ) { + common::push_field(&mut document, "Event", event_id); + } + if let Some(proof) = proof_summary(result) { + common::push_field(&mut document, "Proof", proof); + } + document +} + +fn push_issues_section(document: &mut TerminalDocument, result: &Value) { + let rows = common::array(result, &["issues"]) + .into_iter() + .flatten() + .map(|issue| { + TerminalTableRow::new(vec![ + common::string(issue, &["field"]).unwrap_or_default(), + common::string(issue, &["message"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "Issues", + vec![ + TerminalTableColumn::new("Field", 5, 18), + TerminalTableColumn::new("Message", 7, 54), + ], + rows, + "No validation issues", + )); +} + +fn push_relay_field(document: &mut TerminalDocument, result: &Value) { + if let Some(summary) = relay_field_summary(result) { + common::push_field(document, "Relays", summary); + } +} + +fn relay_field_summary(result: &Value) -> Option<String> { + let acknowledged = common::array(result, &["acknowledged_relays"]) + .map(Vec::len) + .or_else(|| { + common::number_path(result, &["publish", "acknowledged_count"]) + .and_then(|value| usize::try_from(value).ok()) + }) + .or_else(|| { + common::number_path(result, &["receipt", "acknowledged_count"]) + .and_then(|value| usize::try_from(value).ok()) + }) + .unwrap_or(0); + let failed = common::array(result, &["failed_relays"]) + .map(Vec::len) + .or_else(|| { + common::number_path(result, &["publish", "failed_count"]) + .and_then(|value| usize::try_from(value).ok()) + }) + .or_else(|| { + common::number_path(result, &["receipt", "failed_count"]) + .and_then(|value| usize::try_from(value).ok()) + }) + .unwrap_or(0); + let target = common::array(result, &["target_relays"]) + .map(Vec::len) + .unwrap_or(0); + (acknowledged > 0 || failed > 0 || target > 0) + .then(|| relay_summary(acknowledged, failed, "acknowledged")) +} + +fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> { + paths.iter().find_map(|path| common::string(value, path)) +} + +fn listing_title(operation_id: &str) -> &'static str { + match operation_id { + "listing.update" => "Listing updated", + "listing.archive" => "Listing archived", + _ => "Listing", + } +} + +#[cfg(test)] +mod tests { + use crate::out::envelope::EnvelopeContext; + use crate::out::terminal::renderer::render_terminal_document; + use serde_json::json; + + use super::*; + + #[test] + fn publish_receipt_omits_absent_proof_source() { + let envelope = OutputEnvelope::success( + "listing.publish", + json!({ + "state": "published", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg", + "source": "SDK listing publish · configured signer", + "acknowledged_relays": ["wss://relay.example"], + "failed_relays": [], + "event_id": "9f3ac129f3ac129f3ac129f3ac129f3ac129f3ac129f3ac129f3ac129f3ac12", + "actions": ["radroots listing get AAAAAAAAAAAAAAAAAAAAAg"] + }), + EnvelopeContext::new("req_listing_publish", false), + ); + let document = LISTING_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("✓ Listing published")); + assert!(rendered.contains("Listing")); + assert!(rendered.contains("Relays")); + assert!(rendered.contains("Event")); + assert!(!rendered.contains("Proof")); + assert!(!rendered.contains("verified")); + } + + #[test] + fn publish_receipt_requires_verified_true_for_verified_claim() { + let envelope = OutputEnvelope::success( + "listing.publish", + json!({ + "state": "published", + "listing_id": "AAAAAAAAAAAAAAAAAAAAAg", + "proof_verification": { + "state": "valid", + "cryptographic_proof_verified": false, + "proof_system": "SP1" + } + }), + EnvelopeContext::new("req_listing_publish", false), + ); + let document = LISTING_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("Proof")); + assert!(!rendered.contains("Proof verified")); + assert!(!rendered.contains("verified ·")); + } +} diff --git a/src/out/terminal/renderers/mod.rs b/src/out/terminal/renderers/mod.rs @@ -1,7 +1,9 @@ pub mod account; pub mod common; pub mod config; +pub mod farm; pub mod health; +pub mod listing; pub mod runtime; pub mod store; pub mod sync;