cli

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

commit fb2711c02bec2c399a5a533d19568b759ec68332
parent 11044372b4955bf3f63457700fc4f310d87e925a
Author: triesap <tyson@radroots.org>
Date:   Sat, 27 Jun 2026 07:11:25 +0000

cli: structure terminal registry invariant failures

- replace duplicate renderer panics with typed registry violations
- validate terminal renderer coverage against the operation registry
- render registry drift as a structured internal terminal failure
- remove duplicate terminal registry operation count assertion

Diffstat:
Msrc/main.rs | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Msrc/out/terminal/registry.rs | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 295 insertions(+), 32 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -13,6 +13,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; +use serde_json::json; use crate::cli::input::runtime_invocation_args_from_target; use crate::cli::{TargetCliArgs, TargetOutputFormat}; @@ -26,12 +27,16 @@ use crate::ops::{ OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService, TargetOperationRequest, }; -use crate::out::envelope::{CliExitCode, OutputEnvelope, OutputError}; +use crate::out::envelope::{CliExitCode, EnvelopeContext, OutputEnvelope, OutputError}; +use crate::out::terminal::errors::terminal_error_document; +use crate::out::terminal::registry::TerminalRendererRegistryError; use crate::out::terminal::registry::terminal_renderer_registry; use crate::out::terminal::renderer::{ TerminalRenderContext, TerminalVerbosity, render_terminal_document, }; -use crate::registry::{NetworkRequirement, network_requirement, requires_local_signer_mode}; +use crate::registry::{ + NetworkRequirement, OPERATION_REGISTRY, network_requirement, requires_local_signer_mode, +}; use crate::runtime::config::{ OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, Verbosity, }; @@ -59,15 +64,13 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> { render_config_from_target_args(&args, args.format.unwrap_or(TargetOutputFormat::Terminal)); if let Err(error) = validate_pre_runtime_request_contract(&request) { let envelope = failure_envelope(&request, error); - render_envelope(&envelope, &pre_runtime_render_config)?; - return Ok(envelope_exit_code(&envelope)); + return render_envelope(&envelope, &pre_runtime_render_config); } let config = match RuntimeConfig::from_system(&runtime_invocation_args_from_target(&args)) { Ok(config) => config, Err(error) => { let envelope = runtime_config_failure_envelope(&request, error.into()); - render_envelope(&envelope, &pre_runtime_render_config)?; - return Ok(envelope_exit_code(&envelope)); + return render_envelope(&envelope, &pre_runtime_render_config); } }; request.set_output_format(OperationOutputFormat::from(config.output.format)); @@ -76,16 +79,14 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> { Ok(logging) => logging, Err(error) => { let envelope = runtime_config_failure_envelope(&request, error.into()); - render_envelope(&envelope, &runtime_render_config)?; - return Ok(envelope_exit_code(&envelope)); + return render_envelope(&envelope, &runtime_render_config); } }; let envelope = match validate_request_contract(&request, &config) { Ok(()) => execute_request(request, &config, &logging), Err(error) => failure_envelope(&request, error), }; - render_envelope(&envelope, &runtime_render_config)?; - Ok(envelope_exit_code(&envelope)) + render_envelope(&envelope, &runtime_render_config) } fn execute_request( @@ -594,7 +595,7 @@ fn terminal_verbosity_from_runtime(verbosity: Verbosity) -> TerminalVerbosity { fn render_envelope( envelope: &OutputEnvelope, config: &EnvelopeRenderConfig, -) -> Result<(), runtime::RuntimeError> { +) -> Result<ExitCode, runtime::RuntimeError> { match config.format { RenderOutputFormat::Terminal => render_terminal_envelope(envelope, &config.terminal), RenderOutputFormat::Json => { @@ -602,7 +603,7 @@ fn render_envelope( let mut handle = stdout.lock(); serde_json::to_writer_pretty(&mut handle, envelope)?; writeln!(handle)?; - Ok(()) + Ok(envelope_exit_code(envelope)) } RenderOutputFormat::Ndjson => { let stdout = std::io::stdout(); @@ -611,7 +612,7 @@ fn render_envelope( serde_json::to_writer(&mut handle, &frame)?; writeln!(handle)?; } - Ok(()) + Ok(envelope_exit_code(envelope)) } } } @@ -619,17 +620,22 @@ fn render_envelope( fn render_terminal_envelope( envelope: &OutputEnvelope, cx: &TerminalRenderContext, -) -> Result<(), runtime::RuntimeError> { +) -> Result<ExitCode, runtime::RuntimeError> { let registry = terminal_renderer_registry(); - let document = registry - .get(envelope.operation_id.as_str()) - .ok_or_else(|| { - runtime::RuntimeError::Config(format!( - "missing terminal renderer for {}", - envelope.operation_id - )) - })? - .render(envelope, cx); + if let Err(error) = registry.validate_against_operations(OPERATION_REGISTRY) { + let failure = terminal_registry_failure_envelope(envelope, error); + render_terminal_failure_document(&failure, cx)?; + return Ok(envelope_exit_code(&failure)); + } + let renderer = match registry.renderer_for(envelope.operation_id.as_str()) { + Ok(renderer) => renderer, + Err(error) => { + let failure = terminal_registry_failure_envelope(envelope, error); + render_terminal_failure_document(&failure, cx)?; + return Ok(envelope_exit_code(&failure)); + } + }; + let document = renderer.render(envelope, cx); let rendered = render_terminal_document(&document, cx); if envelope.errors.is_empty() { let stdout = std::io::stdout(); @@ -640,9 +646,52 @@ fn render_terminal_envelope( let mut handle = stderr.lock(); writeln!(handle, "{rendered}")?; } + Ok(envelope_exit_code(envelope)) +} + +fn render_terminal_failure_document( + envelope: &OutputEnvelope, + cx: &TerminalRenderContext, +) -> Result<(), runtime::RuntimeError> { + let document = terminal_error_document(envelope); + let rendered = render_terminal_document(&document, cx); + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + writeln!(handle, "{rendered}")?; Ok(()) } +fn terminal_registry_failure_envelope( + envelope: &OutputEnvelope, + error: TerminalRendererRegistryError, +) -> OutputEnvelope { + let mut output_error = OutputError::new( + "internal_error", + format!( + "terminal renderer registry invariant failed for {}", + envelope.operation_id + ), + CliExitCode::InternalError, + ); + output_error.detail = Some(json!({ + "class": "internal", + "operation_id": envelope.operation_id, + "violations": error.violations().iter().map(|violation| { + json!({ + "kind": violation.kind(), + "operation_id": violation.operation_id(), + "count": violation.count(), + }) + }).collect::<Vec<_>>(), + })); + let mut context = EnvelopeContext::new(envelope.request_id.clone(), envelope.dry_run); + context.output_format = envelope.output_format; + context.correlation_id = envelope.correlation_id.clone(); + context.idempotency_key = envelope.idempotency_key.clone(); + context.actor = envelope.actor.clone(); + OutputEnvelope::failure(envelope.operation_id.clone(), output_error, context) +} + fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { envelope .errors @@ -654,3 +703,45 @@ fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { fn operation_config_error(error: OperationAdapterError) -> runtime::RuntimeError { runtime::RuntimeError::Config(error.to_string()) } + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use crate::out::envelope::{EnvelopeContext, OutputEnvelope, OutputStatus}; + use crate::out::terminal::errors::terminal_error_document; + use crate::out::terminal::registry::TerminalRendererRegistry; + use crate::out::terminal::renderer::{TerminalRenderContext, render_terminal_document}; + + use super::*; + + #[test] + fn terminal_registry_failure_envelope_is_structured_internal_error() { + let original = OutputEnvelope::success( + "workspace.get", + Value::Null, + EnvelopeContext::new("req_test", false), + ); + let error = TerminalRendererRegistry::new() + .validate_operation_ids(["workspace.get"]) + .expect_err("missing renderer should fail"); + let failure = terminal_registry_failure_envelope(&original, error); + + assert_eq!(failure.status, OutputStatus::Error); + assert_eq!(failure.reason_code.as_deref(), Some("internal_error")); + assert_eq!(failure.errors[0].code, "internal_error"); + let detail = failure.errors[0].detail.as_ref().expect("error detail"); + assert_eq!(detail["violations"][0]["kind"], "missing"); + + let rendered = render_terminal_document( + &terminal_error_document(&failure), + &TerminalRenderContext::default(), + ); + assert!(rendered.starts_with("✕ Command failed\n")); + assert!( + rendered + .contains("Reason terminal renderer registry invariant failed for workspace.get") + ); + assert!(!rendered.contains("RuntimeError::Config")); + } +} diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -1,4 +1,8 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + use crate::out::envelope::OutputEnvelope; +use crate::registry::OperationSpec; use super::layout::TerminalDocument; use super::renderer::TerminalRenderContext; @@ -10,6 +14,7 @@ pub trait TerminalOperationRenderer { #[derive(Default)] pub struct TerminalRendererRegistry { entries: Vec<TerminalRendererEntry>, + violations: Vec<TerminalRendererRegistryViolation>, } struct TerminalRendererEntry { @@ -21,6 +26,7 @@ impl TerminalRendererRegistry { pub fn new() -> Self { Self { entries: Vec::new(), + violations: Vec::new(), } } @@ -30,7 +36,8 @@ impl TerminalRendererRegistry { renderer: &'static dyn TerminalOperationRenderer, ) -> Self { if self.contains(operation_id) { - panic!("duplicate terminal renderer registration for {operation_id}"); + self.violations + .push(TerminalRendererRegistryViolation::duplicate(operation_id)); } self.entries.push(TerminalRendererEntry { operation_id, @@ -46,10 +53,12 @@ impl TerminalRendererRegistry { } pub fn get(&self, operation_id: &str) -> Option<&'static dyn TerminalOperationRenderer> { - self.entries + let mut matches = self + .entries .iter() - .find(|entry| entry.operation_id == operation_id) - .map(|entry| entry.renderer) + .filter(|entry| entry.operation_id == operation_id); + let renderer = matches.next()?.renderer; + matches.next().is_none().then_some(renderer) } pub fn len(&self) -> usize { @@ -59,6 +68,146 @@ impl TerminalRendererRegistry { pub fn operation_ids(&self) -> impl Iterator<Item = &'static str> + '_ { self.entries.iter().map(|entry| entry.operation_id) } + + pub fn renderer_for( + &self, + operation_id: &str, + ) -> Result<&'static dyn TerminalOperationRenderer, TerminalRendererRegistryError> { + self.get(operation_id).ok_or_else(|| { + TerminalRendererRegistryError::from_violation( + TerminalRendererRegistryViolation::missing(operation_id.to_owned()), + ) + }) + } + + pub fn validate_against_operations( + &self, + operations: &[OperationSpec], + ) -> Result<(), TerminalRendererRegistryError> { + self.validate_operation_ids(operations.iter().map(|operation| operation.operation_id)) + } + + pub fn validate_operation_ids<'a>( + &self, + expected: impl IntoIterator<Item = &'a str>, + ) -> Result<(), TerminalRendererRegistryError> { + let expected = expected.into_iter().collect::<BTreeSet<_>>(); + let mut counts = BTreeMap::<&str, usize>::new(); + for operation_id in self.operation_ids() { + *counts.entry(operation_id).or_default() += 1; + } + let mut violations = self.violations.clone(); + for operation_id in expected { + match counts.get(operation_id).copied().unwrap_or(0) { + 0 => { + violations.push(TerminalRendererRegistryViolation::missing( + operation_id.to_owned(), + )); + } + 1 => {} + count => { + violations.push(TerminalRendererRegistryViolation::duplicate_count( + operation_id.to_owned(), + count, + )); + } + } + } + violations.sort(); + violations.dedup(); + if violations.is_empty() { + Ok(()) + } else { + Err(TerminalRendererRegistryError { violations }) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum TerminalRendererRegistryViolation { + DuplicateOperation { operation_id: String, count: usize }, + MissingOperation { operation_id: String }, +} + +impl TerminalRendererRegistryViolation { + fn duplicate(operation_id: impl Into<String>) -> Self { + Self::DuplicateOperation { + operation_id: operation_id.into(), + count: 2, + } + } + + fn duplicate_count(operation_id: impl Into<String>, count: usize) -> Self { + Self::DuplicateOperation { + operation_id: operation_id.into(), + count, + } + } + + fn missing(operation_id: impl Into<String>) -> Self { + Self::MissingOperation { + operation_id: operation_id.into(), + } + } + + pub fn kind(&self) -> &'static str { + match self { + Self::DuplicateOperation { .. } => "duplicate", + Self::MissingOperation { .. } => "missing", + } + } + + pub fn operation_id(&self) -> &str { + match self { + Self::DuplicateOperation { operation_id, .. } + | Self::MissingOperation { operation_id } => operation_id, + } + } + + pub fn count(&self) -> Option<usize> { + match self { + Self::DuplicateOperation { count, .. } => Some(*count), + Self::MissingOperation { .. } => None, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TerminalRendererRegistryError { + violations: Vec<TerminalRendererRegistryViolation>, +} + +impl TerminalRendererRegistryError { + pub fn from_violation(violation: TerminalRendererRegistryViolation) -> Self { + Self { + violations: vec![violation], + } + } + + pub fn violations(&self) -> &[TerminalRendererRegistryViolation] { + &self.violations + } +} + +impl fmt::Display for TerminalRendererRegistryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("terminal renderer registry invariant failed")?; + for violation in &self.violations { + match violation { + TerminalRendererRegistryViolation::DuplicateOperation { + operation_id, + count, + } => write!( + formatter, + "; duplicate renderer for {operation_id} ({count})" + )?, + TerminalRendererRegistryViolation::MissingOperation { operation_id } => { + write!(formatter, "; missing renderer for {operation_id}")? + } + } + } + Ok(()) + } } pub fn terminal_renderer_registry() -> TerminalRendererRegistry { @@ -80,8 +229,6 @@ 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; @@ -112,11 +259,34 @@ mod tests { } #[test] - #[should_panic(expected = "duplicate terminal renderer registration for workspace.get")] fn duplicate_operation_renderer_registration_fails() { - let _registry = TerminalRendererRegistry::new() + let registry = TerminalRendererRegistry::new() .register("workspace.get", &TEST_RENDERER) .register("workspace.get", &TEST_RENDERER); + let error = registry + .validate_operation_ids(["workspace.get"]) + .expect_err("duplicate renderer registration should fail"); + + assert_eq!( + error.violations(), + &[TerminalRendererRegistryViolation::duplicate_count( + "workspace.get", + 2 + )] + ); + assert!(registry.get("workspace.get").is_none()); + } + + #[test] + fn missing_operation_renderer_registration_fails() { + let error = TerminalRendererRegistry::new() + .validate_operation_ids(["workspace.get"]) + .expect_err("missing renderer registration should fail"); + + assert_eq!( + error.violations(), + &[TerminalRendererRegistryViolation::missing("workspace.get")] + ); } #[test] @@ -130,7 +300,9 @@ mod tests { assert_eq!(registry.len(), OPERATION_REGISTRY.len()); assert_eq!(actual, expected); - assert_eq!(registry.len(), 76); + registry + .validate_against_operations(OPERATION_REGISTRY) + .expect("terminal registry covers operation registry"); for operation_id in expected { assert!( registry.contains(operation_id),