cli

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

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:
Msrc/out/mod.rs | 1+
Asrc/out/terminal/actions.rs | 165+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/errors.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/layout.rs | 222+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/mod.rs | 9+++++++++
Asrc/out/terminal/registry.rs | 90+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/renderer.rs | 250+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/tables.rs | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/out/terminal/values.rs | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/order.rs | 5++++-
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 &section.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 {