commit 19f25db775d15f7625cc3b768db634fde31ce32e
parent c52fdb9364be4a67611fa88ca3da106708e2ea3e
Author: triesap <tyson@radroots.org>
Date: Sat, 27 Jun 2026 01:59:06 +0000
terminal: add presentation foundation
- add the terminal document, table, value, action, and error model
- add a deterministic terminal document writer and render context
- add a renderer registry foundation for operation-specific coverage
- prove layout, action, table, and proof formatting with focused tests
Diffstat:
10 files changed, 1070 insertions(+), 1 deletion(-)
diff --git a/src/out/mod.rs b/src/out/mod.rs
@@ -1 +1,2 @@
pub mod envelope;
+pub mod terminal;
diff --git a/src/out/terminal/actions.rs b/src/out/terminal/actions.rs
@@ -0,0 +1,165 @@
+use crate::out::envelope::{NextAction, NextActionKind};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalActionKind {
+ Command,
+ Setup,
+ Placeholder,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalAction {
+ pub label: Option<String>,
+ pub command: Option<String>,
+ pub description: Option<String>,
+ pub kind: TerminalActionKind,
+}
+
+impl TerminalAction {
+ pub fn command(command: impl Into<String>) -> Self {
+ Self {
+ label: None,
+ command: Some(command.into()),
+ description: None,
+ kind: TerminalActionKind::Command,
+ }
+ }
+
+ pub fn setup(description: impl Into<String>) -> Self {
+ Self {
+ label: None,
+ command: None,
+ description: Some(description.into()),
+ kind: TerminalActionKind::Setup,
+ }
+ }
+
+ pub fn placeholder(command: impl Into<String>) -> Self {
+ Self {
+ label: None,
+ command: Some(command.into()),
+ description: None,
+ kind: TerminalActionKind::Placeholder,
+ }
+ }
+
+ pub fn render_line(&self) -> Option<String> {
+ self.command
+ .clone()
+ .or_else(|| self.description.clone())
+ .or_else(|| self.label.clone())
+ }
+}
+
+pub fn terminal_actions_from_next_actions(actions: &[NextAction]) -> Vec<TerminalAction> {
+ actions
+ .iter()
+ .filter_map(terminal_action_from_next_action)
+ .fold(Vec::<TerminalAction>::new(), |mut unique, action| {
+ if !unique.contains(&action) {
+ unique.push(action);
+ }
+ unique
+ })
+}
+
+fn terminal_action_from_next_action(action: &NextAction) -> Option<TerminalAction> {
+ match action.kind {
+ NextActionKind::CliCommand => action
+ .command
+ .as_deref()
+ .map(terminal_action_from_command)
+ .or_else(|| action.description.clone().map(TerminalAction::setup)),
+ NextActionKind::OperatorConfig => action
+ .description
+ .clone()
+ .map(TerminalAction::setup)
+ .or_else(|| Some(TerminalAction::setup(action.label.clone()))),
+ }
+}
+
+pub fn terminal_action_from_command(command: &str) -> TerminalAction {
+ let command = command.trim();
+ if command_requires_placeholder(command) {
+ TerminalAction::placeholder(command_with_placeholder(command))
+ } else {
+ TerminalAction::command(command)
+ }
+}
+
+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"
+ )
+}
+
+fn command_with_placeholder(command: &str) -> String {
+ 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 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()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::out::envelope::NextAction;
+
+ #[test]
+ fn exact_command_renders_as_command() {
+ let action = terminal_action_from_command("radroots store init");
+
+ assert_eq!(action.kind, TerminalActionKind::Command);
+ assert_eq!(action.command.as_deref(), Some("radroots store init"));
+ }
+
+ #[test]
+ fn incomplete_command_is_placeholdered() {
+ let action = terminal_action_from_command("radroots basket item add");
+
+ assert_eq!(action.kind, TerminalActionKind::Placeholder);
+ assert_eq!(
+ action.command.as_deref(),
+ Some("radroots basket item add <basket> --listing <product> --bin-id <bin>")
+ );
+ }
+
+ #[test]
+ fn converts_operator_config_action_to_setup() {
+ let action = NextAction {
+ kind: NextActionKind::OperatorConfig,
+ label: "configure token".to_owned(),
+ command: None,
+ description: Some("configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()),
+ env_var: Some("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE".to_owned()),
+ config_key: None,
+ };
+
+ let terminal = terminal_actions_from_next_actions(&[action]);
+
+ assert_eq!(terminal.len(), 1);
+ assert_eq!(terminal[0].kind, TerminalActionKind::Setup);
+ assert_eq!(
+ terminal[0].description.as_deref(),
+ Some("configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE")
+ );
+ }
+}
diff --git a/src/out/terminal/errors.rs b/src/out/terminal/errors.rs
@@ -0,0 +1,55 @@
+use crate::out::envelope::{OutputEnvelope, OutputError};
+
+use super::actions::terminal_actions_from_next_actions;
+use super::layout::{TerminalDocument, TerminalField, TerminalHeader, TerminalSymbol};
+
+pub fn terminal_error_document(envelope: &OutputEnvelope) -> TerminalDocument {
+ let error = envelope.errors.first();
+ let title = error
+ .map(error_title)
+ .unwrap_or_else(|| "Command failed".to_owned());
+ let mut document = TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Failure, title));
+ if let Some(error) = error {
+ document
+ .fields
+ .push(TerminalField::new("Reason", error.message.clone()));
+ document
+ .fields
+ .push(TerminalField::verbose("Code", error.reason_code.clone()));
+ }
+ document.next = terminal_actions_from_next_actions(&envelope.next_actions);
+ document
+}
+
+fn error_title(error: &OutputError) -> String {
+ match error.code.as_str() {
+ "not_found" => "Not found",
+ "invalid_input" => "Invalid input",
+ "approval_required" => "Approval required",
+ "operation_unavailable" => "Unavailable",
+ _ => "Command failed",
+ }
+ .to_owned()
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::out::envelope::{CliExitCode, EnvelopeContext, OutputEnvelope, OutputError};
+
+ use super::*;
+
+ #[test]
+ fn builds_terminal_error_document() {
+ let error = OutputError::new("invalid_input", "missing input", CliExitCode::InvalidInput);
+ let envelope = OutputEnvelope::failure(
+ "trade.submit",
+ error,
+ EnvelopeContext::new("req_test", false),
+ );
+ let document = terminal_error_document(&envelope);
+
+ assert_eq!(document.header.title, "Invalid input");
+ assert_eq!(document.fields[0].label, "Reason");
+ assert_eq!(document.fields[0].value, "missing input");
+ }
+}
diff --git a/src/out/terminal/layout.rs b/src/out/terminal/layout.rs
@@ -0,0 +1,222 @@
+use super::actions::TerminalAction;
+use super::tables::TerminalTable;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalSymbol {
+ Success,
+ Neutral,
+ Attention,
+ Failure,
+}
+
+impl TerminalSymbol {
+ pub fn glyph(self) -> &'static str {
+ match self {
+ Self::Success => "✓",
+ Self::Neutral => "◌",
+ Self::Attention => "!",
+ Self::Failure => "✕",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalVisibility {
+ Normal,
+ Verbose,
+ Trace,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalHeader {
+ pub symbol: TerminalSymbol,
+ pub title: String,
+}
+
+impl TerminalHeader {
+ pub fn new(symbol: TerminalSymbol, title: impl Into<String>) -> Self {
+ Self {
+ symbol,
+ title: title.into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalField {
+ pub label: String,
+ pub value: String,
+ pub visibility: TerminalVisibility,
+}
+
+impl TerminalField {
+ pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self {
+ Self {
+ label: label.into(),
+ value: value.into(),
+ visibility: TerminalVisibility::Normal,
+ }
+ }
+
+ pub fn verbose(label: impl Into<String>, value: impl Into<String>) -> Self {
+ Self {
+ label: label.into(),
+ value: value.into(),
+ visibility: TerminalVisibility::Verbose,
+ }
+ }
+
+ pub fn trace(label: impl Into<String>, value: impl Into<String>) -> Self {
+ Self {
+ label: label.into(),
+ value: value.into(),
+ visibility: TerminalVisibility::Trace,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum TerminalSectionBody {
+ Lines(Vec<String>),
+ Fields(Vec<TerminalField>),
+ Table(TerminalTable),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalSection {
+ pub title: String,
+ pub body: TerminalSectionBody,
+ pub visibility: TerminalVisibility,
+}
+
+impl TerminalSection {
+ pub fn lines(title: impl Into<String>, lines: Vec<String>) -> Self {
+ Self {
+ title: title.into(),
+ body: TerminalSectionBody::Lines(lines),
+ visibility: TerminalVisibility::Normal,
+ }
+ }
+
+ pub fn fields(title: impl Into<String>, fields: Vec<TerminalField>) -> Self {
+ Self {
+ title: title.into(),
+ body: TerminalSectionBody::Fields(fields),
+ visibility: TerminalVisibility::Normal,
+ }
+ }
+
+ pub fn table(title: impl Into<String>, table: TerminalTable) -> Self {
+ Self {
+ title: title.into(),
+ body: TerminalSectionBody::Table(table),
+ visibility: TerminalVisibility::Normal,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalWarning {
+ pub code: String,
+ pub message: String,
+}
+
+impl TerminalWarning {
+ pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
+ Self {
+ code: code.into(),
+ message: message.into(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct TerminalReference {
+ pub request_id: Option<String>,
+ pub correlation_id: Option<String>,
+ pub idempotency_key: Option<String>,
+ pub event_id: Option<String>,
+ pub event_addr: Option<String>,
+ pub job_id: Option<String>,
+ pub path: Option<String>,
+ pub source: Option<String>,
+}
+
+impl TerminalReference {
+ pub fn is_empty(&self) -> bool {
+ self.request_id.is_none()
+ && self.correlation_id.is_none()
+ && self.idempotency_key.is_none()
+ && self.event_id.is_none()
+ && self.event_addr.is_none()
+ && self.job_id.is_none()
+ && self.path.is_none()
+ && self.source.is_none()
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalDocument {
+ pub header: TerminalHeader,
+ pub fields: Vec<TerminalField>,
+ pub sections: Vec<TerminalSection>,
+ pub warnings: Vec<TerminalWarning>,
+ pub next: Vec<TerminalAction>,
+ pub reference: Option<TerminalReference>,
+}
+
+impl TerminalDocument {
+ pub fn new(header: TerminalHeader) -> Self {
+ Self {
+ header,
+ fields: Vec::new(),
+ sections: Vec::new(),
+ warnings: Vec::new(),
+ next: Vec::new(),
+ reference: None,
+ }
+ }
+
+ pub fn with_field(mut self, field: TerminalField) -> Self {
+ self.fields.push(field);
+ self
+ }
+
+ pub fn with_section(mut self, section: TerminalSection) -> Self {
+ self.sections.push(section);
+ self
+ }
+
+ pub fn with_next(mut self, action: TerminalAction) -> Self {
+ self.next.push(action);
+ self
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn symbols_match_terminal_contract() {
+ assert_eq!(TerminalSymbol::Success.glyph(), "✓");
+ assert_eq!(TerminalSymbol::Neutral.glyph(), "◌");
+ assert_eq!(TerminalSymbol::Attention.glyph(), "!");
+ assert_eq!(TerminalSymbol::Failure.glyph(), "✕");
+ }
+
+ #[test]
+ fn document_defaults_to_empty_body() {
+ let document = TerminalDocument::new(TerminalHeader::new(
+ TerminalSymbol::Success,
+ "Workspace ready",
+ ));
+
+ assert_eq!(document.header.title, "Workspace ready");
+ assert!(document.fields.is_empty());
+ assert!(document.sections.is_empty());
+ assert!(document.warnings.is_empty());
+ assert!(document.next.is_empty());
+ assert!(document.reference.is_none());
+ }
+}
diff --git a/src/out/terminal/mod.rs b/src/out/terminal/mod.rs
@@ -0,0 +1,9 @@
+#![allow(dead_code)]
+
+pub mod actions;
+pub mod errors;
+pub mod layout;
+pub mod registry;
+pub mod renderer;
+pub mod tables;
+pub mod values;
diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs
@@ -0,0 +1,90 @@
+use crate::out::envelope::OutputEnvelope;
+
+use super::layout::TerminalDocument;
+use super::renderer::TerminalRenderContext;
+
+pub trait TerminalOperationRenderer {
+ fn render(&self, envelope: &OutputEnvelope, cx: &TerminalRenderContext) -> TerminalDocument;
+}
+
+#[derive(Default)]
+pub struct TerminalRendererRegistry {
+ entries: Vec<TerminalRendererEntry>,
+}
+
+struct TerminalRendererEntry {
+ operation_id: &'static str,
+ renderer: &'static dyn TerminalOperationRenderer,
+}
+
+impl TerminalRendererRegistry {
+ pub fn new() -> Self {
+ Self {
+ entries: Vec::new(),
+ }
+ }
+
+ pub fn register(
+ mut self,
+ operation_id: &'static str,
+ renderer: &'static dyn TerminalOperationRenderer,
+ ) -> Self {
+ if !self.contains(operation_id) {
+ self.entries.push(TerminalRendererEntry {
+ operation_id,
+ renderer,
+ });
+ }
+ self
+ }
+
+ pub fn contains(&self, operation_id: &str) -> bool {
+ self.entries
+ .iter()
+ .any(|entry| entry.operation_id == operation_id)
+ }
+
+ pub fn get(&self, operation_id: &str) -> Option<&'static dyn TerminalOperationRenderer> {
+ self.entries
+ .iter()
+ .find(|entry| entry.operation_id == operation_id)
+ .map(|entry| entry.renderer)
+ }
+
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::out::terminal::layout::{TerminalDocument, TerminalHeader, TerminalSymbol};
+
+ use super::*;
+
+ struct TestRenderer;
+
+ impl TerminalOperationRenderer for TestRenderer {
+ fn render(
+ &self,
+ _envelope: &OutputEnvelope,
+ _cx: &TerminalRenderContext,
+ ) -> TerminalDocument {
+ TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Success, "ok"))
+ }
+ }
+
+ static TEST_RENDERER: TestRenderer = TestRenderer;
+
+ #[test]
+ fn registers_unique_operation_renderers() {
+ let registry = TerminalRendererRegistry::new()
+ .register("workspace.get", &TEST_RENDERER)
+ .register("workspace.get", &TEST_RENDERER);
+
+ assert!(registry.contains("workspace.get"));
+ assert_eq!(registry.len(), 1);
+ assert!(registry.get("workspace.get").is_some());
+ assert!(registry.get("missing").is_none());
+ }
+}
diff --git a/src/out/terminal/renderer.rs b/src/out/terminal/renderer.rs
@@ -0,0 +1,250 @@
+use std::fmt::Write as _;
+
+use super::actions::TerminalAction;
+use super::layout::{
+ TerminalDocument, TerminalField, TerminalReference, TerminalSection, TerminalSectionBody,
+ TerminalVisibility,
+};
+use super::tables::{TerminalTable, truncate_cell};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalVerbosity {
+ Quiet,
+ Normal,
+ Verbose,
+ Trace,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TerminalColorPolicy {
+ Auto,
+ Always,
+ Never,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalRenderContext {
+ pub verbosity: TerminalVerbosity,
+ pub color: TerminalColorPolicy,
+ pub width: usize,
+ pub stdout_is_tty: bool,
+ pub stderr_is_tty: bool,
+ pub dry_run: bool,
+}
+
+impl Default for TerminalRenderContext {
+ fn default() -> Self {
+ Self {
+ verbosity: TerminalVerbosity::Normal,
+ color: TerminalColorPolicy::Auto,
+ width: 80,
+ stdout_is_tty: false,
+ stderr_is_tty: false,
+ dry_run: false,
+ }
+ }
+}
+
+impl TerminalRenderContext {
+ pub fn allows(&self, visibility: TerminalVisibility) -> bool {
+ match visibility {
+ TerminalVisibility::Normal => true,
+ TerminalVisibility::Verbose => {
+ matches!(
+ self.verbosity,
+ TerminalVerbosity::Verbose | TerminalVerbosity::Trace
+ )
+ }
+ TerminalVisibility::Trace => matches!(self.verbosity, TerminalVerbosity::Trace),
+ }
+ }
+}
+
+pub fn render_terminal_document(document: &TerminalDocument, cx: &TerminalRenderContext) -> String {
+ let mut output = String::new();
+ let _ = writeln!(
+ output,
+ "{} {}",
+ document.header.symbol.glyph(),
+ document.header.title
+ );
+ write_fields(&mut output, &document.fields, cx);
+ for section in &document.sections {
+ write_section(&mut output, section, cx);
+ }
+ if !document.warnings.is_empty() {
+ let _ = writeln!(output);
+ let _ = writeln!(output, "Warnings");
+ for warning in &document.warnings {
+ let _ = writeln!(output, " {}: {}", warning.code, warning.message);
+ }
+ }
+ write_next(&mut output, &document.next);
+ if let Some(reference) = &document.reference
+ && cx.allows(TerminalVisibility::Verbose)
+ && !reference.is_empty()
+ {
+ write_reference(&mut output, reference);
+ }
+ output.trim_end_matches('\n').to_owned()
+}
+
+fn write_fields(output: &mut String, fields: &[TerminalField], cx: &TerminalRenderContext) {
+ let fields = fields
+ .iter()
+ .filter(|field| cx.allows(field.visibility))
+ .collect::<Vec<_>>();
+ if fields.is_empty() {
+ return;
+ }
+ let width = fields
+ .iter()
+ .map(|field| field.label.chars().count())
+ .max()
+ .unwrap_or(0);
+ let _ = writeln!(output);
+ for field in fields {
+ let _ = writeln!(
+ output,
+ " {label:<width$} {value}",
+ label = field.label,
+ width = width,
+ value = field.value
+ );
+ }
+}
+
+fn write_section(output: &mut String, section: &TerminalSection, cx: &TerminalRenderContext) {
+ if !cx.allows(section.visibility) {
+ return;
+ }
+ let _ = writeln!(output);
+ let _ = writeln!(output, "{}", section.title);
+ match §ion.body {
+ TerminalSectionBody::Lines(lines) => {
+ for line in lines {
+ let _ = writeln!(output, " {line}");
+ }
+ }
+ TerminalSectionBody::Fields(fields) => write_fields(output, fields, cx),
+ TerminalSectionBody::Table(table) => write_table(output, table, cx.width),
+ }
+}
+
+fn write_table(output: &mut String, table: &TerminalTable, width: usize) {
+ if table.rows.is_empty() {
+ if let Some(empty) = &table.empty {
+ let _ = writeln!(output, " {empty}");
+ }
+ return;
+ }
+ let widths = table.column_widths(width.saturating_sub(2));
+ let labels = table
+ .columns
+ .iter()
+ .zip(widths.iter())
+ .map(|(column, width)| {
+ format!(
+ "{:<width$}",
+ truncate_cell(&column.label, *width),
+ width = *width
+ )
+ })
+ .collect::<Vec<_>>()
+ .join(" ");
+ let _ = writeln!(output, " {labels}");
+ for row in &table.rows {
+ let line = widths
+ .iter()
+ .enumerate()
+ .map(|(index, width)| {
+ let cell = row.cells.get(index).map(String::as_str).unwrap_or("");
+ format!("{:<width$}", truncate_cell(cell, *width), width = *width)
+ })
+ .collect::<Vec<_>>()
+ .join(" ");
+ let _ = writeln!(output, " {line}");
+ }
+}
+
+fn write_next(output: &mut String, actions: &[TerminalAction]) {
+ let visible = actions
+ .iter()
+ .filter_map(|action| action.render_line())
+ .collect::<Vec<_>>();
+ if visible.is_empty() {
+ return;
+ }
+ let _ = writeln!(output);
+ let _ = writeln!(output, "Next");
+ for action in visible {
+ let _ = writeln!(output, " {action}");
+ }
+}
+
+fn write_reference(output: &mut String, reference: &TerminalReference) {
+ let fields = [
+ ("Request", reference.request_id.as_ref()),
+ ("Correlation", reference.correlation_id.as_ref()),
+ ("Idempotency", reference.idempotency_key.as_ref()),
+ ("Event", reference.event_id.as_ref()),
+ ("Address", reference.event_addr.as_ref()),
+ ("Job", reference.job_id.as_ref()),
+ ("Path", reference.path.as_ref()),
+ ("Source", reference.source.as_ref()),
+ ]
+ .into_iter()
+ .filter_map(|(label, value)| value.map(|value| TerminalField::new(label, value)))
+ .collect::<Vec<_>>();
+ let _ = writeln!(output);
+ let _ = writeln!(output, "Reference");
+ write_fields(output, &fields, &TerminalRenderContext::default());
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::out::terminal::actions::TerminalAction;
+ use crate::out::terminal::layout::{TerminalField, TerminalHeader, TerminalSymbol};
+
+ #[test]
+ fn renders_canonical_receipt_layout() {
+ let document = TerminalDocument::new(TerminalHeader::new(
+ TerminalSymbol::Success,
+ "Listing published",
+ ))
+ .with_field(TerminalField::new("Listing", "AAAAAAAAAAAAAAAAAAAAAg"))
+ .with_field(TerminalField::new("Transport", "direct nostr relay"))
+ .with_field(TerminalField::new("Relays", "2 acknowledged · 0 failed"))
+ .with_field(TerminalField::new("Event", "9f3a…c12"))
+ .with_next(TerminalAction::command(
+ "radroots listing get AAAAAAAAAAAAAAAAAAAAAg",
+ ));
+
+ assert_eq!(
+ render_terminal_document(&document, &TerminalRenderContext::default()),
+ "✓ Listing published\n\n Listing AAAAAAAAAAAAAAAAAAAAAg\n Transport direct nostr relay\n Relays 2 acknowledged · 0 failed\n Event 9f3a…c12\n\nNext\n radroots listing get AAAAAAAAAAAAAAAAAAAAAg"
+ );
+ }
+
+ #[test]
+ fn hides_reference_until_verbose() {
+ let mut document =
+ TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Success, "Ready"));
+ document.reference = Some(TerminalReference {
+ request_id: Some("req_test".to_owned()),
+ ..TerminalReference::default()
+ });
+
+ assert_eq!(
+ render_terminal_document(&document, &TerminalRenderContext::default()),
+ "✓ Ready"
+ );
+
+ let cx = TerminalRenderContext {
+ verbosity: TerminalVerbosity::Verbose,
+ ..TerminalRenderContext::default()
+ };
+ assert!(render_terminal_document(&document, &cx).contains("Reference"));
+ }
+}
diff --git a/src/out/terminal/tables.rs b/src/out/terminal/tables.rs
@@ -0,0 +1,123 @@
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalTableColumn {
+ pub label: String,
+ pub min_width: usize,
+ pub max_width: usize,
+}
+
+impl TerminalTableColumn {
+ pub fn new(label: impl Into<String>, min_width: usize, max_width: usize) -> Self {
+ Self {
+ label: label.into(),
+ min_width,
+ max_width: max_width.max(min_width),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalTableRow {
+ pub cells: Vec<String>,
+}
+
+impl TerminalTableRow {
+ pub fn new(cells: Vec<impl Into<String>>) -> Self {
+ Self {
+ cells: cells.into_iter().map(Into::into).collect(),
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TerminalTable {
+ pub columns: Vec<TerminalTableColumn>,
+ pub rows: Vec<TerminalTableRow>,
+ pub empty: Option<String>,
+}
+
+impl TerminalTable {
+ pub fn new(columns: Vec<TerminalTableColumn>) -> Self {
+ Self {
+ columns,
+ rows: Vec::new(),
+ empty: None,
+ }
+ }
+
+ pub fn with_row(mut self, row: TerminalTableRow) -> Self {
+ self.rows.push(row);
+ self
+ }
+
+ pub fn with_empty(mut self, empty: impl Into<String>) -> Self {
+ self.empty = Some(empty.into());
+ self
+ }
+
+ pub fn column_widths(&self, width: usize) -> Vec<usize> {
+ if self.columns.is_empty() {
+ return Vec::new();
+ }
+ let separators = self.columns.len().saturating_sub(1) * 2;
+ let available = width.saturating_sub(separators).max(self.columns.len());
+ let mut widths = self
+ .columns
+ .iter()
+ .map(|column| column.min_width.min(column.max_width))
+ .collect::<Vec<_>>();
+ let min_total = widths.iter().sum::<usize>();
+ let mut remaining = available.saturating_sub(min_total);
+ for (index, column) in self.columns.iter().enumerate() {
+ if remaining == 0 {
+ break;
+ }
+ let current = widths[index];
+ let target = column.max_width.max(current);
+ let add = target.saturating_sub(current).min(remaining);
+ widths[index] += add;
+ remaining -= add;
+ }
+ widths
+ }
+}
+
+pub fn truncate_cell(value: &str, width: usize) -> String {
+ if width == 0 {
+ return String::new();
+ }
+ let chars = value.chars().collect::<Vec<_>>();
+ if chars.len() <= width {
+ return value.to_owned();
+ }
+ if width == 1 {
+ return "…".to_owned();
+ }
+ chars
+ .into_iter()
+ .take(width.saturating_sub(1))
+ .collect::<String>()
+ + "…"
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn truncates_cells_deterministically() {
+ assert_eq!(truncate_cell("abcdef", 4), "abc…");
+ assert_eq!(truncate_cell("abcdef", 1), "…");
+ assert_eq!(truncate_cell("abc", 4), "abc");
+ }
+
+ #[test]
+ fn computes_stable_column_widths() {
+ let table = TerminalTable::new(vec![
+ TerminalTableColumn::new("Name", 4, 8),
+ TerminalTableColumn::new("State", 5, 10),
+ ]);
+
+ assert_eq!(table.column_widths(20), vec![8, 10]);
+ assert_eq!(table.column_widths(10), vec![4, 5]);
+ }
+}
diff --git a/src/out/terminal/values.rs b/src/out/terminal/values.rs
@@ -0,0 +1,151 @@
+use std::path::Path;
+
+use serde_json::Value;
+
+pub fn transport_label(value: &str) -> String {
+ match value {
+ "direct_nostr_relay" => "direct nostr relay",
+ "radrootsd_proxy" => "radrootsd proxy",
+ "local" => "local",
+ "preview" => "preview",
+ other => other,
+ }
+ .to_owned()
+}
+
+pub fn relay_summary(success: usize, failed: usize, success_label: &str) -> String {
+ format!("{success} {success_label} · {failed} failed")
+}
+
+pub fn yes_no(value: bool) -> &'static str {
+ if value { "yes" } else { "no" }
+}
+
+pub fn ready_blocked(value: bool) -> &'static str {
+ if value { "ready" } else { "blocked" }
+}
+
+pub fn plural(count: usize, singular: &str, plural: &str) -> String {
+ if count == 1 {
+ format!("1 {singular}")
+ } else {
+ format!("{count} {plural}")
+ }
+}
+
+pub fn quantity_label(amount: impl ToString, unit: Option<&str>) -> String {
+ match unit.filter(|unit| !unit.trim().is_empty()) {
+ Some(unit) => format!("{} {unit}", amount.to_string()),
+ None => format!("quantity {}", amount.to_string()),
+ }
+}
+
+pub fn price_label(amount: impl ToString, currency: &str, unit: Option<&str>) -> String {
+ match unit.filter(|unit| !unit.trim().is_empty()) {
+ Some(unit) => format!("{} {currency} / {unit}", amount.to_string()),
+ None => format!("{} {currency}", amount.to_string()),
+ }
+}
+
+pub fn compact_id(value: &str, head: usize, tail: usize) -> String {
+ let chars = value.chars().collect::<Vec<_>>();
+ if chars.len() <= head + tail + 1 {
+ return value.to_owned();
+ }
+ let start = chars.iter().take(head).collect::<String>();
+ let end = chars
+ .iter()
+ .skip(chars.len().saturating_sub(tail))
+ .collect::<String>();
+ format!("{start}…{end}")
+}
+
+pub fn path_label(path: &Path) -> String {
+ path.display().to_string()
+}
+
+pub fn string_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> {
+ let mut current = value;
+ for segment in path {
+ current = current.get(*segment)?;
+ }
+ current.as_str().filter(|value| !value.trim().is_empty())
+}
+
+pub fn bool_path(value: &Value, path: &[&str]) -> Option<bool> {
+ let mut current = value;
+ for segment in path {
+ current = current.get(*segment)?;
+ }
+ current.as_bool()
+}
+
+pub fn proof_summary(value: &Value) -> Option<String> {
+ if bool_path(
+ value,
+ &["proof_verification", "cryptographic_proof_verified"],
+ ) == Some(true)
+ {
+ let system = string_path(value, &["proof_verification", "proof_system"])
+ .or_else(|| string_path(value, &["receipt", "proof", "system"]))
+ .unwrap_or("cryptographic proof");
+ return Some(format!("verified · {system}"));
+ }
+ if string_path(value, &["proof_verification", "state"]).is_some() {
+ return Some("available".to_owned());
+ }
+ None
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn formats_domain_values() {
+ assert_eq!(transport_label("direct_nostr_relay"), "direct nostr relay");
+ assert_eq!(
+ relay_summary(2, 0, "acknowledged"),
+ "2 acknowledged · 0 failed"
+ );
+ assert_eq!(quantity_label("12", Some("lb")), "12 lb");
+ assert_eq!(quantity_label("3", None), "quantity 3");
+ assert_eq!(price_label("6.00", "CAD", Some("1 lb")), "6.00 CAD / 1 lb");
+ assert_eq!(plural(1, "relay", "relays"), "1 relay");
+ assert_eq!(plural(2, "relay", "relays"), "2 relays");
+ }
+
+ #[test]
+ fn compacts_long_ids() {
+ assert_eq!(compact_id("1234567890abcdef", 4, 3), "1234…def");
+ assert_eq!(compact_id("short", 4, 3), "short");
+ }
+
+ #[test]
+ fn proof_summary_requires_verified_true_for_verified_label() {
+ assert_eq!(
+ proof_summary(&json!({
+ "proof_verification": {
+ "cryptographic_proof_verified": true,
+ "proof_system": "SP1"
+ }
+ }))
+ .as_deref(),
+ Some("verified · SP1")
+ );
+ assert_eq!(
+ proof_summary(&json!({
+ "proof_verification": {
+ "state": "valid",
+ "cryptographic_proof_verified": false,
+ "proof_system": "SP1"
+ }
+ }))
+ .as_deref(),
+ Some("available")
+ );
+ assert_eq!(proof_summary(&json!({})), None);
+ }
+}
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -1971,7 +1971,10 @@ fn enrich_order_status_inventory(
.cloned()
.collect::<Vec<_>>();
if relevant_issues.is_empty() {
- if matches!(view.state.as_str(), "pending_rhi" | "committed" | "cancelled") {
+ if matches!(
+ view.state.as_str(),
+ "pending_rhi" | "committed" | "cancelled"
+ ) {
let inventory_state = if view.state == "cancelled" {
"released"
} else {