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:
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"])