cli

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

commit 043976991719efd661104e88b73ecab083d3bcb7
parent e472c3ce6aacbd82ac5f67abbc2ec1703bc80123
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 02:58:25 +0000

terminal: add trade renderers

- register trade and validation receipt terminal renderers
- render trade lifecycle, status, event, and app-record views
- render validation receipt list and proof inspection output
- replace stale validation receipt order-id recovery action

Diffstat:
Msrc/out/terminal/registry.rs | 24++++++++++++++++++++++--
Msrc/out/terminal/renderers/mod.rs | 2++
Asrc/out/terminal/renderers/trade.rs | 639+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderers/validation.rs | 263+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/registry/validation.rs | 2+-
Msrc/runtime/validation_receipt.rs | 2+-
6 files changed, 928 insertions(+), 4 deletions(-)

diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -68,7 +68,9 @@ pub fn terminal_renderer_registry() -> TerminalRendererRegistry { let registry = crate::out::terminal::renderers::farm::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) + let registry = crate::out::terminal::renderers::basket::register(registry); + let registry = crate::out::terminal::renderers::trade::register(registry); + crate::out::terminal::renderers::validation::register(registry) } #[cfg(test)] @@ -104,7 +106,7 @@ mod tests { } #[test] - fn registry_covers_core_seller_and_buyer_operations() { + fn registry_covers_registered_operations() { let registry = terminal_renderer_registry(); let expected = [ "workspace.init", @@ -165,6 +167,24 @@ mod tests { "basket.adjustment.remove", "basket.validate", "basket.quote.create", + "trade.submit", + "trade.get", + "trade.list", + "trade.app.list", + "trade.app.export", + "trade.rebind", + "trade.accept", + "trade.decline", + "trade.cancel", + "trade.revision.propose", + "trade.revision.accept", + "trade.revision.decline", + "trade.status.get", + "trade.event.list", + "trade.event.watch", + "validation.receipt.get", + "validation.receipt.list", + "validation.receipt.verify", ]; assert_eq!(registry.len(), expected.len()); diff --git a/src/out/terminal/renderers/mod.rs b/src/out/terminal/renderers/mod.rs @@ -9,4 +9,6 @@ pub mod market; pub mod runtime; pub mod store; pub mod sync; +pub mod trade; +pub mod validation; pub mod workspace; diff --git a/src/out/terminal/renderers/trade.rs b/src/out/terminal/renderers/trade.rs @@ -0,0 +1,639 @@ +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::{ready_blocked, relay_summary, yes_no}; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("trade.submit", &TRADE_RENDERER) + .register("trade.get", &TRADE_RENDERER) + .register("trade.list", &TRADE_RENDERER) + .register("trade.app.list", &TRADE_RENDERER) + .register("trade.app.export", &TRADE_RENDERER) + .register("trade.rebind", &TRADE_RENDERER) + .register("trade.accept", &TRADE_RENDERER) + .register("trade.decline", &TRADE_RENDERER) + .register("trade.cancel", &TRADE_RENDERER) + .register("trade.revision.propose", &TRADE_RENDERER) + .register("trade.revision.accept", &TRADE_RENDERER) + .register("trade.revision.decline", &TRADE_RENDERER) + .register("trade.status.get", &TRADE_RENDERER) + .register("trade.event.list", &TRADE_RENDERER) + .register("trade.event.watch", &TRADE_RENDERER) +} + +struct TradeRenderer; + +static TRADE_RENDERER: TradeRenderer = TradeRenderer; + +impl TerminalOperationRenderer for TradeRenderer { + 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() { + "trade.list" => trade_list_document(envelope, result), + "trade.app.list" => trade_app_list_document(envelope, result), + "trade.app.export" => trade_app_export_document(envelope, result), + "trade.get" => trade_detail_document(envelope, result), + "trade.rebind" => trade_rebind_document(envelope, result), + "trade.status.get" => trade_status_document(envelope, result), + "trade.event.list" => trade_event_list_document(envelope, result), + "trade.event.watch" => trade_event_watch_document(envelope, result), + "trade.submit" => trade_publish_document(envelope, result), + "trade.accept" | "trade.decline" => trade_decision_document(envelope, result), + "trade.cancel" => trade_cancel_document(envelope, result), + "trade.revision.propose" => trade_revision_propose_document(envelope, result), + "trade.revision.accept" | "trade.revision.decline" => { + trade_revision_decision_document(envelope, result) + } + _ => trade_detail_document(envelope, result), + } + } +} + +fn trade_publish_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade submit"); + push_trade_identity_fields(&mut document, result); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_bool_field(&mut document, "Deduplicated", result, &["deduplicated"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "Kind", result, &["event_kind"]); + common::push_path_field(&mut document, "Signer", result, &["signer_mode"]); + push_relay_field(&mut document, result); + push_job_fields(&mut document, result); + push_trade_items_section(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_decision_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title( + envelope, + trade_decision_title(envelope.operation_id.as_str()), + ); + push_trade_identity_fields(&mut document, result); + common::push_path_field(&mut document, "Decision", result, &["decision"]); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_path_field(&mut document, "Request", result, &["request_event_id"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "Kind", result, &["event_kind"]); + common::push_path_field(&mut document, "Signer", result, &["signer_mode"]); + push_relay_field(&mut document, result); + push_inventory_section(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_cancel_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade cancel"); + push_trade_identity_fields(&mut document, result); + common::push_path_field( + &mut document, + "Cancel reason", + result, + &["cancellation_reason"], + ); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_path_field(&mut document, "Request", result, &["request_event_id"]); + common::push_path_field(&mut document, "Decision", result, &["decision_event_id"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "Signer", result, &["signer_mode"]); + push_relay_field(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_revision_propose_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade revision propose"); + push_trade_identity_fields(&mut document, result); + common::push_path_field(&mut document, "Revision", result, &["revision_id"]); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_path_field(&mut document, "Request", result, &["request_event_id"]); + common::push_path_field(&mut document, "Decision", result, &["decision_event_id"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "Signer", result, &["signer_mode"]); + push_relay_field(&mut document, result); + push_trade_items_section(&mut document, result); + push_inventory_section(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_revision_decision_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title( + envelope, + trade_revision_title(envelope.operation_id.as_str()), + ); + push_trade_identity_fields(&mut document, result); + common::push_path_field(&mut document, "Revision", result, &["revision_id"]); + common::push_path_field(&mut document, "Decision", result, &["decision"]); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_path_field(&mut document, "Request", result, &["request_event_id"]); + common::push_path_field(&mut document, "Agreement", result, &["agreement_event_id"]); + common::push_path_field(&mut document, "Event", result, &["event_id"]); + common::push_path_field(&mut document, "Signer", result, &["signer_mode"]); + push_relay_field(&mut document, result); + push_inventory_section(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_detail_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Trade"); + push_trade_identity_fields(&mut document, result); + common::push_bool_field(&mut document, "Submit ready", result, &["ready_for_submit"]); + common::push_path_field( + &mut document, + "Buyer account", + result, + &["buyer_account_id"], + ); + common::push_path_field(&mut document, "Buyer custody", result, &["buyer_custody"]); + common::push_bool_field( + &mut document, + "Buyer can write", + result, + &["buyer_write_capable"], + ); + common::push_path_field(&mut document, "Updated", result, &["updated_at_unix"]); + push_job_fields(&mut document, result); + push_workflow_fields(&mut document, result); + push_trade_items_section(&mut document, result); + push_issue_sections(&mut document, result); + document +} + +fn trade_rebind_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade rebind"); + common::push_path_field(&mut document, "Lookup", result, &["lookup"]); + common::push_path_field(&mut document, "From", result, &["from_trade_id"]); + common::push_path_field(&mut document, "To", result, &["to_trade_id"]); + common::push_bool_field( + &mut document, + "Trade changed", + result, + &["trade_id_changed"], + ); + common::push_path_field( + &mut document, + "From account", + result, + &["from_buyer_account_id"], + ); + common::push_path_field( + &mut document, + "To account", + result, + &["to_buyer_account_id"], + ); + common::push_bool_field( + &mut document, + "Buyer changed", + result, + &["buyer_pubkey_changed"], + ); + common::push_path_field( + &mut document, + "Existing request", + result, + &["existing_request_check"], + ); + push_lines_section( + &mut document, + "Existing requests", + result, + &["existing_request_event_ids"], + ); + document +} + +fn trade_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Trades"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + let rows = common::array(result, &["trades"]) + .into_iter() + .flatten() + .map(|trade| { + TerminalTableRow::new(vec![ + common::string(trade, &["id"]).unwrap_or_default(), + common::string(trade, &["state"]).unwrap_or_default(), + common::bool_path(trade, &["ready_for_submit"]) + .map(ready_blocked) + .unwrap_or_default() + .to_owned(), + common::number_label_path(trade, &["item_count"]).unwrap_or_default(), + common::string(trade, &["listing_addr"]) + .or_else(|| common::string(trade, &["listing_lookup"])) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Trades", + vec![ + TerminalTableColumn::new("Trade", 6, 24), + TerminalTableColumn::new("State", 5, 14), + TerminalTableColumn::new("Submit", 6, 8), + TerminalTableColumn::new("Items", 5, 5), + TerminalTableColumn::new("Listing", 8, 28), + ], + rows, + "No trade drafts found", + )); + document +} + +fn trade_app_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Trade 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, &["status"]).unwrap_or_default(), + common::string(record, &["trade_id"]).unwrap_or_default(), + common::bool_path(record, &["exportable"]) + .map(yes_no) + .unwrap_or_default() + .to_owned(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Records", + vec![ + TerminalTableColumn::new("Record", 8, 28), + TerminalTableColumn::new("Status", 6, 14), + TerminalTableColumn::new("Trade", 6, 24), + TerminalTableColumn::new("Export", 6, 6), + ], + rows, + "No app-authored trade records found", + )); + document +} + +fn trade_app_export_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade app export"); + common::push_path_field(&mut document, "Record", result, &["record_id"]); + common::push_path_field(&mut document, "Trade", result, &["trade_id"]); + common::push_bool_field(&mut document, "Dry run", result, &["dry_run"]); + common::push_bool_field(&mut document, "Valid", result, &["valid"]); + common::push_path_field(&mut document, "Listing", result, &["listing_addr"]); + common::push_path_field( + &mut document, + "Buyer account", + result, + &["buyer_account_id"], + ); + push_issue_sections(&mut document, result); + document +} + +fn trade_status_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade status"); + push_trade_identity_fields(&mut document, result); + common::push_path_field( + &mut document, + "Actor context", + result, + &["actor_context_source"], + ); + common::push_path_field(&mut document, "Last event", result, &["last_event_id"]); + common::push_path_field(&mut document, "Lifecycle", result, &["lifecycle", "phase"]); + common::push_bool_field( + &mut document, + "Terminal", + result, + &["lifecycle", "terminal"], + ); + common::push_path_field(&mut document, "Revision", result, &["revision", "state"]); + push_relay_field(&mut document, result); + push_inventory_section(&mut document, result); + push_issue_sections(&mut document, result); + push_nested_issues_section( + &mut document, + "Lifecycle issues", + result, + &["lifecycle", "issues"], + ); + push_nested_issues_section(&mut document, "Reducer issues", result, &["reducer_issues"]); + document +} + +fn trade_event_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_title(envelope, "Trade events"); + common::push_path_field(&mut document, "State", result, &["state"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + common::push_path_field( + &mut document, + "Actor context", + result, + &["actor_context_source"], + ); + common::push_path_field(&mut document, "Seller", result, &["seller_pubkey"]); + push_relay_field(&mut document, result); + let rows = common::array(result, &["trades"]) + .into_iter() + .flatten() + .map(|trade| { + TerminalTableRow::new(vec![ + common::string(trade, &["id"]).unwrap_or_default(), + common::string(trade, &["state"]).unwrap_or_default(), + common::string(trade, &["event_id"]).unwrap_or_default(), + common::number_label_path(trade, &["event_kind"]).unwrap_or_default(), + common::string(trade, &["listing_addr"]) + .or_else(|| common::string(trade, &["listing_lookup"])) + .unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Events", + vec![ + TerminalTableColumn::new("Trade", 6, 22), + TerminalTableColumn::new("State", 5, 14), + TerminalTableColumn::new("Event", 5, 22), + TerminalTableColumn::new("Kind", 4, 6), + TerminalTableColumn::new("Listing", 8, 24), + ], + rows, + "No trade events found", + )); + document +} + +fn trade_event_watch_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Trade event watch"); + common::push_path_field(&mut document, "Trade", result, &["trade_id"]); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + document +} + +fn push_trade_identity_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field(document, "State", result, &["state"]); + common::push_path_field(document, "Trade", result, &["trade_id"]); + common::push_path_field(document, "Lookup", result, &["lookup"]); + common::push_path_field(document, "Listing", result, &["listing_addr"]); + common::push_path_field(document, "Listing", result, &["listing_lookup"]); + common::push_path_field(document, "Listing event", result, &["listing_event_id"]); + common::push_path_field(document, "Buyer", result, &["buyer_pubkey"]); + common::push_path_field(document, "Seller", result, &["seller_pubkey"]); + common::push_path_field(document, "Reason", result, &["reason"]); +} + +fn push_relay_field(document: &mut TerminalDocument, result: &Value) { + let acknowledged = array_len(result, &["acknowledged_relays"]); + let connected = array_len(result, &["connected_relays"]); + let failed = array_len(result, &["failed_relays"]); + if acknowledged > 0 || failed > 0 { + common::push_field( + document, + "Relays", + relay_summary(acknowledged, failed, "acknowledged"), + ); + } else if connected > 0 || failed > 0 { + common::push_field( + document, + "Relays", + relay_summary(connected, failed, "connected"), + ); + } +} + +fn push_job_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field(document, "Job", result, &["job", "job_id"]); + common::push_path_field(document, "Job state", result, &["job", "state"]); + common::push_path_field(document, "Job event", result, &["job", "event_id"]); +} + +fn push_workflow_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field(document, "Workflow", result, &["workflow", "state"]); + common::push_path_field( + document, + "Workflow event", + result, + &["workflow", "last_event_id"], + ); +} + +fn push_trade_items_section(document: &mut TerminalDocument, result: &Value) { + let rows = common::array(result, &["items"]) + .into_iter() + .flatten() + .map(|item| { + TerminalTableRow::new(vec![ + common::string(item, &["bin_id"]).unwrap_or_default(), + common::number_label_path(item, &["bin_count"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "Items", + vec![ + TerminalTableColumn::new("Bin", 3, 18), + TerminalTableColumn::new("Count", 5, 8), + ], + rows, + "No trade items", + )); +} + +fn push_inventory_section(document: &mut TerminalDocument, result: &Value) { + let Some(inventory) = result.get("inventory") else { + return; + }; + common::push_path_field(document, "Inventory", inventory, &["state"]); + common::push_bool_field(document, "Commitment", inventory, &["commitment_valid"]); + let rows = common::array(inventory, &["bins"]) + .into_iter() + .flatten() + .map(|bin| { + TerminalTableRow::new(vec![ + common::string(bin, &["bin_id"]).unwrap_or_default(), + common::number_label_path(bin, &["committed_count"]).unwrap_or_default(), + common::number_label_path(bin, &["available_count"]).unwrap_or_default(), + common::number_label_path(bin, &["remaining_count"]).unwrap_or_default(), + common::bool_path(bin, &["over_reserved"]) + .map(yes_no) + .unwrap_or_default() + .to_owned(), + ]) + }) + .collect::<Vec<_>>(); + if !rows.is_empty() { + document.sections.push(common::table_section( + "Inventory", + vec![ + TerminalTableColumn::new("Bin", 3, 16), + TerminalTableColumn::new("Commit", 6, 8), + TerminalTableColumn::new("Avail", 5, 8), + TerminalTableColumn::new("Remain", 6, 8), + TerminalTableColumn::new("Over", 4, 5), + ], + rows, + "No inventory bins", + )); + } + push_nested_issues_section(document, "Inventory issues", inventory, &["issues"]); +} + +fn push_issue_sections(document: &mut TerminalDocument, result: &Value) { + push_nested_issues_section(document, "Issues", result, &["issues"]); +} + +fn push_nested_issues_section( + document: &mut TerminalDocument, + title: &str, + value: &Value, + path: &[&str], +) { + let rows = issue_rows(value, path); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + title, + vec![ + TerminalTableColumn::new("Code", 8, 32), + TerminalTableColumn::new("Field", 8, 24), + TerminalTableColumn::new("Message", 7, 48), + ], + rows, + "No trade issues", + )); +} + +fn issue_rows(value: &Value, path: &[&str]) -> Vec<TerminalTableRow> { + common::array(value, 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 push_lines_section(document: &mut TerminalDocument, title: &str, value: &Value, path: &[&str]) { + let lines = common::array(value, path) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::<Vec<_>>(); + if !lines.is_empty() { + document + .sections + .push(crate::out::terminal::layout::TerminalSection::lines( + title, lines, + )); + } +} + +fn array_len(value: &Value, path: &[&str]) -> usize { + common::array(value, path).map(Vec::len).unwrap_or_default() +} + +fn trade_decision_title(operation_id: &str) -> &'static str { + match operation_id { + "trade.accept" => "Trade accept", + "trade.decline" => "Trade decline", + _ => "Trade decision", + } +} + +fn trade_revision_title(operation_id: &str) -> &'static str { + match operation_id { + "trade.revision.accept" => "Trade revision accept", + "trade.revision.decline" => "Trade revision decline", + _ => "Trade revision decision", + } +} + +#[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) -> OutputEnvelope { + let mut envelope = OutputEnvelope::success( + operation_id, + result, + EnvelopeContext::new(format!("req_{operation_id}"), false), + ); + envelope.next_actions = next_actions_from_result_value(&envelope.result); + envelope + } + + #[test] + fn trade_list_uses_trade_surface_without_internal_draft_path() { + let envelope = success_envelope( + "trade.list", + json!({ + "state": "ready", + "count": 1, + "trades": [{ + "id": "trade_test", + "state": "ready", + "ready_for_submit": true, + "file": "orders/drafts/trade_test.toml", + "item_count": 1, + "listing_addr": "listing_test" + }], + "actions": ["radroots trade get trade_test"] + }), + ); + let document = TRADE_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("Trades")); + assert!(rendered.contains("trade_test")); + assert!(rendered.contains("radroots trade get trade_test")); + assert!(!rendered.contains("orders/drafts")); + assert!(!rendered.contains("Order")); + } + + #[test] + fn event_watch_result_is_deferred_status_not_stream_claim() { + let envelope = success_envelope( + "trade.event.watch", + json!({ + "state": "not_implemented", + "trade_id": "trade_test", + "reason": "relay-backed trade event watch is not implemented", + "actions": ["radroots trade status get trade_test"] + }), + ); + let document = TRADE_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("Trade event watch")); + assert!(rendered.contains("not implemented")); + assert!(rendered.contains("radroots trade status get trade_test")); + assert!(!rendered.contains("stream")); + } +} diff --git a/src/out/terminal/renderers/validation.rs b/src/out/terminal/renderers/validation.rs @@ -0,0 +1,263 @@ +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; + +use super::common; + +pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { + registry + .register("validation.receipt.get", &VALIDATION_RENDERER) + .register("validation.receipt.list", &VALIDATION_RENDERER) + .register("validation.receipt.verify", &VALIDATION_RENDERER) +} + +struct ValidationRenderer; + +static VALIDATION_RENDERER: ValidationRenderer = ValidationRenderer; + +impl TerminalOperationRenderer for ValidationRenderer { + 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() { + "validation.receipt.list" => validation_receipt_list_document(envelope, result), + "validation.receipt.verify" => validation_receipt_inspection_document( + envelope, + result, + "Validation receipt verification", + ), + _ => validation_receipt_inspection_document(envelope, result, "Validation receipt"), + } + } +} + +fn validation_receipt_inspection_document( + envelope: &OutputEnvelope, + result: &Value, + title: &str, +) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, title); + common::push_path_field(&mut document, "Receipt", result, &["receipt_event_id"]); + common::push_path_field(&mut document, "Trade", result, &["order_id"]); + common::push_path_field(&mut document, "Validation", result, &["validation_state"]); + push_proof_fields(&mut document, result); + common::push_path_field(&mut document, "Event", result, &["event", "id"]); + common::push_path_field(&mut document, "Author", result, &["event", "author"]); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + push_relay_field(&mut document, result); + push_receipt_tag_fields(&mut document, result); + document +} + +fn validation_receipt_list_document(envelope: &OutputEnvelope, result: &Value) -> TerminalDocument { + let mut document = common::document_with_status_title(envelope, "Validation receipts"); + common::push_path_field(&mut document, "Trade", result, &["order_id"]); + common::push_count_field(&mut document, "Count", result, &["count"]); + common::push_count_field(&mut document, "Valid", result, &["valid_count"]); + common::push_count_field(&mut document, "Invalid", result, &["invalid_count"]); + common::push_path_field(&mut document, "Reason", result, &["reason"]); + push_relay_field(&mut document, result); + let rows = common::array(result, &["receipts"]) + .into_iter() + .flatten() + .map(|receipt| { + TerminalTableRow::new(vec![ + common::string(receipt, &["receipt_event_id"]).unwrap_or_default(), + common::string(receipt, &["result"]).unwrap_or_default(), + proof_summary_from_summary(receipt), + common::string(receipt, &["receipt_type"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + document.sections.push(common::table_section( + "Receipts", + vec![ + TerminalTableColumn::new("Receipt", 7, 24), + TerminalTableColumn::new("Result", 6, 12), + TerminalTableColumn::new("Proof", 5, 18), + TerminalTableColumn::new("Type", 4, 18), + ], + rows, + "No validation receipts found", + )); + push_invalid_receipts_section(&mut document, result); + document +} + +fn push_proof_fields(document: &mut TerminalDocument, result: &Value) { + let Some(proof) = result.get("proof_verification") else { + return; + }; + common::push_field(document, "Proof", proof_state_label(proof)); + common::push_path_field(document, "Proof system", proof, &["proof_system"]); + common::push_bool_field( + document, + "Cryptographic proof", + proof, + &["cryptographic_proof_verified"], + ); + common::push_path_field(document, "Verifier", proof, &["verifier"]); + common::push_path_field(document, "Proof mode", proof, &["mode"]); + common::push_path_field(document, "Program", proof, &["program_hash"]); + common::push_path_field(document, "Verifying key", proof, &["verifying_key_hash"]); + common::push_path_field(document, "Proof reason", proof, &["reason"]); +} + +fn push_receipt_tag_fields(document: &mut TerminalDocument, result: &Value) { + common::push_path_field( + document, + "Receipt type", + result, + &["receipt_tags", "receipt_type"], + ); + common::push_path_field( + document, + "Root event", + result, + &["receipt_tags", "root_event_id"], + ); + common::push_path_field( + document, + "Target event", + result, + &["receipt_tags", "target_event_id"], + ); +} + +fn push_invalid_receipts_section(document: &mut TerminalDocument, result: &Value) { + let rows = common::array(result, &["invalid_receipts"]) + .into_iter() + .flatten() + .map(|receipt| { + TerminalTableRow::new(vec![ + common::string(receipt, &["receipt_event_id"]).unwrap_or_default(), + common::string(receipt, &["reason_code"]).unwrap_or_default(), + common::string(receipt, &["reason"]).unwrap_or_default(), + ]) + }) + .collect::<Vec<_>>(); + if rows.is_empty() { + return; + } + document.sections.push(common::table_section( + "Invalid receipts", + vec![ + TerminalTableColumn::new("Receipt", 7, 24), + TerminalTableColumn::new("Code", 8, 28), + TerminalTableColumn::new("Reason", 6, 46), + ], + rows, + "No invalid validation receipts", + )); +} + +fn push_relay_field(document: &mut TerminalDocument, result: &Value) { + let connected = array_len(result, &["connected_relays"]); + let failed = array_len(result, &["failed_relays"]); + if connected > 0 || failed > 0 { + common::push_field( + document, + "Relays", + relay_summary(connected, failed, "connected"), + ); + } +} + +fn proof_state_label(proof: &Value) -> String { + if common::bool_path(proof, &["cryptographic_proof_verified"]) == Some(true) { + return "verified".to_owned(); + } + if common::bool_path(proof, &["cryptographic_proof_required"]) == Some(true) { + return "unverified".to_owned(); + } + "not required".to_owned() +} + +fn proof_summary_from_summary(receipt: &Value) -> String { + match common::string(receipt, &["proof_verification_state"]).as_deref() { + Some("deterministic_receipt_verified") => "not required".to_owned(), + Some("sp1_execute_checked") => "not required".to_owned(), + Some(_) => "available".to_owned(), + None => String::new(), + } +} + +fn array_len(value: &Value, path: &[&str]) -> usize { + common::array(value, path).map(Vec::len).unwrap_or_default() +} + +#[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) -> OutputEnvelope { + let mut envelope = OutputEnvelope::success( + operation_id, + result, + EnvelopeContext::new(format!("req_{operation_id}"), false), + ); + envelope.next_actions = next_actions_from_result_value(&envelope.result); + envelope + } + + #[test] + fn receipt_list_renders_trade_id_surface() { + let envelope = success_envelope( + "validation.receipt.list", + json!({ + "state": "empty", + "order_id": "trade_test", + "count": 0, + "valid_count": 0, + "invalid_count": 0, + "receipts": [], + "invalid_receipts": [], + "actions": ["radroots --relay wss://relay.example.com validation receipt list --trade-id trade_test"] + }), + ); + let document = VALIDATION_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("Validation receipts")); + assert!(rendered.contains("--trade-id trade_test")); + assert!(!rendered.contains("--order-id")); + } + + #[test] + fn proof_output_does_not_claim_verified_without_crypto_source() { + let envelope = success_envelope( + "validation.receipt.verify", + json!({ + "state": "valid", + "receipt_event_id": "receipt_test", + "order_id": "trade_test", + "validation_state": "valid", + "proof_verification": { + "state": "sp1_reference_unresolved", + "proof_system": "sp1_core", + "cryptographic_proof_required": true, + "cryptographic_proof_verified": false, + "verifier": "radroots_cli_validation_receipt_v1" + }, + "actions": [] + }), + ); + let document = VALIDATION_RENDERER.render(&envelope, &TerminalRenderContext::default()); + let rendered = render_terminal_document(&document, &TerminalRenderContext::default()); + + assert!(rendered.contains("Proof unverified")); + assert!(!rendered.contains("Proof verified")); + } +} diff --git a/src/registry/validation.rs b/src/registry/validation.rs @@ -23,7 +23,7 @@ pub const VALIDATION_RECEIPT_LIST: OperationSpec = operation!( "validation_receipt_list", "ValidationReceiptListRequest", "ValidationReceiptListResult", - "List validation receipts for one order.", + "List validation receipts for one trade.", Any, false, None, diff --git a/src/runtime/validation_receipt.rs b/src/runtime/validation_receipt.rs @@ -672,7 +672,7 @@ fn fetch_error_parts( Vec::new(), Vec::new(), vec![ - "radroots --relay wss://relay.example.com validation receipt list --order-id <order-id>" + "radroots --relay wss://relay.example.com validation receipt list --trade-id <trade-id>" .to_owned(), ], ),