cli

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

commit 65a12e6f9d11efb56bcf9e1533dbf8414d4646db
parent 043976991719efd661104e88b73ecab083d3bcb7
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 03:09:18 +0000

terminal: harden renderer registry

- Harden terminal renderer coverage against OPERATION_REGISTRY
- Fail duplicate renderer registration and remove missing-renderer fallback
- Rename the shared base terminal document helper away from fallback wording
- Cover terminal verbosity and no-color flag behavior

Diffstat:
Msrc/main.rs | 10+++++++---
Msrc/out/terminal/registry.rs | 121+++++++++++++++++++++----------------------------------------------------------
Msrc/out/terminal/renderers/account.rs | 2+-
Msrc/out/terminal/renderers/basket.rs | 2+-
Msrc/out/terminal/renderers/common.rs | 6+++---
Msrc/out/terminal/renderers/config.rs | 2+-
Msrc/out/terminal/renderers/farm.rs | 2+-
Msrc/out/terminal/renderers/health.rs | 2+-
Msrc/out/terminal/renderers/listing.rs | 2+-
Msrc/out/terminal/renderers/market.rs | 2+-
Msrc/out/terminal/renderers/runtime.rs | 2+-
Msrc/out/terminal/renderers/store.rs | 2+-
Msrc/out/terminal/renderers/sync.rs | 2+-
Msrc/out/terminal/renderers/trade.rs | 2+-
Msrc/out/terminal/renderers/validation.rs | 2+-
Msrc/out/terminal/renderers/workspace.rs | 2+-
Mtests/target_cli.rs | 31+++++++++++++++++++++++++++++++
17 files changed, 86 insertions(+), 108 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -31,7 +31,6 @@ use crate::out::terminal::registry::terminal_renderer_registry; use crate::out::terminal::renderer::{ TerminalColorPolicy, TerminalRenderContext, TerminalVerbosity, render_terminal_document, }; -use crate::out::terminal::renderers::common::generic_terminal_document; use crate::registry::{NetworkRequirement, network_requirement, requires_local_signer_mode}; use crate::runtime::config::{ OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, Verbosity, @@ -638,8 +637,13 @@ fn render_terminal_envelope( let registry = terminal_renderer_registry(); let document = registry .get(envelope.operation_id.as_str()) - .map(|renderer| renderer.render(envelope, cx)) - .unwrap_or_else(|| generic_terminal_document(envelope)); + .ok_or_else(|| { + runtime::RuntimeError::Config(format!( + "missing terminal renderer for {}", + envelope.operation_id + )) + })? + .render(envelope, cx); let rendered = render_terminal_document(&document, cx); if envelope.errors.is_empty() { let stdout = std::io::stdout(); diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -29,12 +29,13 @@ impl TerminalRendererRegistry { operation_id: &'static str, renderer: &'static dyn TerminalOperationRenderer, ) -> Self { - if !self.contains(operation_id) { - self.entries.push(TerminalRendererEntry { - operation_id, - renderer, - }); + if self.contains(operation_id) { + panic!("duplicate terminal renderer registration for {operation_id}"); } + self.entries.push(TerminalRendererEntry { + operation_id, + renderer, + }); self } @@ -54,6 +55,10 @@ impl TerminalRendererRegistry { pub fn len(&self) -> usize { self.entries.len() } + + pub fn operation_ids(&self) -> impl Iterator<Item = &'static str> + '_ { + self.entries.iter().map(|entry| entry.operation_id) + } } pub fn terminal_renderer_registry() -> TerminalRendererRegistry { @@ -75,7 +80,10 @@ pub fn terminal_renderer_registry() -> TerminalRendererRegistry { #[cfg(test)] mod tests { + use std::collections::BTreeSet; + use crate::out::terminal::layout::{TerminalDocument, TerminalHeader, TerminalSymbol}; + use crate::registry::OPERATION_REGISTRY; use super::*; @@ -94,10 +102,8 @@ mod tests { 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); + fn registers_operation_renderers() { + let registry = TerminalRendererRegistry::new().register("workspace.get", &TEST_RENDERER); assert!(registry.contains("workspace.get")); assert_eq!(registry.len(), 1); @@ -106,88 +112,25 @@ mod tests { } #[test] + #[should_panic(expected = "duplicate terminal renderer registration for workspace.get")] + fn duplicate_operation_renderer_registration_fails() { + let _registry = TerminalRendererRegistry::new() + .register("workspace.get", &TEST_RENDERER) + .register("workspace.get", &TEST_RENDERER); + } + + #[test] fn registry_covers_registered_operations() { let registry = terminal_renderer_registry(); - let expected = [ - "workspace.init", - "workspace.get", - "health.status.get", - "health.check.run", - "config.get", - "account.create", - "account.import", - "account.attach_secret", - "account.get", - "account.list", - "account.remove", - "account.selection.get", - "account.selection.update", - "account.selection.clear", - "signer.status.get", - "relay.list", - "store.init", - "store.status.get", - "store.export", - "store.backup.create", - "store.backup.restore", - "sync.status.get", - "sync.pull", - "sync.push", - "sync.watch", - "farm.create", - "farm.get", - "farm.rebind", - "farm.profile.update", - "farm.location.set", - "farm.location.get", - "farm.location.clear", - "farm.fulfillment.update", - "farm.readiness.check", - "farm.publish", - "listing.create", - "listing.get", - "listing.list", - "listing.app.list", - "listing.app.export", - "listing.update", - "listing.validate", - "listing.rebind", - "listing.publish", - "listing.archive", - "market.refresh", - "market.product.search", - "market.listing.get", - "basket.create", - "basket.get", - "basket.list", - "basket.item.add", - "basket.item.update", - "basket.item.remove", - "basket.adjustment.add", - "basket.adjustment.remove", - "basket.validate", - "basket.quote.create", - "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()); + let expected = OPERATION_REGISTRY + .iter() + .map(|operation| operation.operation_id) + .collect::<BTreeSet<_>>(); + let actual = registry.operation_ids().collect::<BTreeSet<_>>(); + + assert_eq!(registry.len(), OPERATION_REGISTRY.len()); + assert_eq!(actual, expected); + assert_eq!(registry.len(), 76); for operation_id in expected { assert!( registry.contains(operation_id), diff --git a/src/out/terminal/renderers/account.rs b/src/out/terminal/renderers/account.rs @@ -28,7 +28,7 @@ static ACCOUNT_RENDERER: AccountRenderer = AccountRenderer; impl TerminalOperationRenderer for AccountRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); let mut document = diff --git a/src/out/terminal/renderers/basket.rs b/src/out/terminal/renderers/basket.rs @@ -29,7 +29,7 @@ static BASKET_RENDERER: BasketRenderer = BasketRenderer; impl TerminalOperationRenderer for BasketRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/common.rs b/src/out/terminal/renderers/common.rs @@ -10,7 +10,7 @@ use crate::out::terminal::layout::{ use crate::out::terminal::tables::{TerminalTable, TerminalTableColumn, TerminalTableRow}; use crate::out::terminal::values::{proof_summary, string_path, transport_label}; -pub(crate) fn generic_terminal_document(envelope: &OutputEnvelope) -> TerminalDocument { +pub(crate) fn base_terminal_document(envelope: &OutputEnvelope) -> TerminalDocument { let display = terminal_display_source(envelope); let mut document = if envelope.errors.is_empty() { let status = terminal_envelope_status(envelope); @@ -40,7 +40,7 @@ pub(crate) fn document_with_title( envelope: &OutputEnvelope, title: impl Into<String>, ) -> TerminalDocument { - let mut document = generic_terminal_document(envelope); + let mut document = base_terminal_document(envelope); document.header.title = title.into(); document } @@ -50,7 +50,7 @@ pub(crate) fn document_with_status_title( title_prefix: &str, ) -> TerminalDocument { let status = terminal_envelope_status(envelope); - let mut document = generic_terminal_document(envelope); + let mut document = base_terminal_document(envelope); document.header.symbol = terminal_status_symbol(status, envelope.dry_run); document.header.title = format!("{title_prefix} {}", terminal_status_label(status)); document diff --git a/src/out/terminal/renderers/config.rs b/src/out/terminal/renderers/config.rs @@ -17,7 +17,7 @@ static CONFIG_RENDERER: ConfigRenderer = ConfigRenderer; impl TerminalOperationRenderer for ConfigRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); let publish_state = diff --git a/src/out/terminal/renderers/farm.rs b/src/out/terminal/renderers/farm.rs @@ -30,7 +30,7 @@ static FARM_RENDERER: FarmRenderer = FarmRenderer; impl TerminalOperationRenderer for FarmRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/health.rs b/src/out/terminal/renderers/health.rs @@ -18,7 +18,7 @@ static HEALTH_RENDERER: HealthRenderer = HealthRenderer; impl TerminalOperationRenderer for HealthRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); let title = match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/listing.rs b/src/out/terminal/renderers/listing.rs @@ -30,7 +30,7 @@ static LISTING_RENDERER: ListingRenderer = ListingRenderer; impl TerminalOperationRenderer for ListingRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/market.rs b/src/out/terminal/renderers/market.rs @@ -23,7 +23,7 @@ static MARKET_RENDERER: MarketRenderer = MarketRenderer; impl TerminalOperationRenderer for MarketRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/runtime.rs b/src/out/terminal/renderers/runtime.rs @@ -21,7 +21,7 @@ static RUNTIME_RENDERER: RuntimeRenderer = RuntimeRenderer; impl TerminalOperationRenderer for RuntimeRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/store.rs b/src/out/terminal/renderers/store.rs @@ -21,7 +21,7 @@ static STORE_RENDERER: StoreRenderer = StoreRenderer; impl TerminalOperationRenderer for StoreRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); let mut document = diff --git a/src/out/terminal/renderers/sync.rs b/src/out/terminal/renderers/sync.rs @@ -20,7 +20,7 @@ static SYNC_RENDERER: SyncRenderer = SyncRenderer; impl TerminalOperationRenderer for SyncRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); let mut document = diff --git a/src/out/terminal/renderers/trade.rs b/src/out/terminal/renderers/trade.rs @@ -35,7 +35,7 @@ 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); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/validation.rs b/src/out/terminal/renderers/validation.rs @@ -23,7 +23,7 @@ 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); + return common::base_terminal_document(envelope); } let result = common::display_source(envelope); match envelope.operation_id.as_str() { diff --git a/src/out/terminal/renderers/workspace.rs b/src/out/terminal/renderers/workspace.rs @@ -18,7 +18,7 @@ static WORKSPACE_RENDERER: WorkspaceRenderer = WorkspaceRenderer; impl TerminalOperationRenderer for WorkspaceRenderer { fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { if !envelope.errors.is_empty() { - return common::generic_terminal_document(envelope); + return common::base_terminal_document(envelope); } let result = common::result(envelope); let mut document = common::document_with_title( diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -2816,6 +2816,37 @@ fn explicit_terminal_output_is_accepted() { } #[test] +fn terminal_global_presentation_flags_are_deterministic() { + for args in [ + &["workspace", "get"][..], + &["--quiet", "workspace", "get"][..], + &["--no-color", "workspace", "get"][..], + ] { + let output = radroots().args(args).output().expect("run terminal flag"); + + assert!(output.status.success(), "{args:?}"); + assert!(output.stderr.is_empty(), "{args:?}"); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.starts_with("✓ Workspace ready\n"), "{args:?}"); + assert!(!stdout.contains("\u{1b}["), "{args:?}"); + } + + for args in [ + &["--verbose", "workspace", "get"][..], + &["--trace", "workspace", "get"][..], + ] { + let output = radroots().args(args).output().expect("run terminal flag"); + + assert!(output.status.success(), "{args:?}"); + assert!(output.stderr.is_empty(), "{args:?}"); + let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); + assert!(stdout.contains("Reference\n"), "{args:?}"); + assert!(stdout.contains("Request"), "{args:?}"); + assert!(!stdout.contains("\u{1b}["), "{args:?}"); + } +} + +#[test] fn human_output_format_is_rejected() { let output = radroots() .args(["--format", "human", "workspace", "get"])