commit 30d4ea579904adc3aa92d6a9fc4257ad212a96cb
parent 19f25db775d15f7625cc3b768db634fde31ce32e
Author: triesap <tyson@radroots.org>
Date: Sat, 27 Jun 2026 02:14:20 +0000
terminal: route terminal output
- make terminal the default text format without a human alias
- honor resolved runtime output config before validation and rendering
- route terminal errors to stderr while keeping machine streams on stdout
- reject stdout logging for every CLI output format
Diffstat:
20 files changed, 532 insertions(+), 235 deletions(-)
diff --git a/.env.example b/.env.example
@@ -1,7 +1,7 @@
# Copy to .env for local CLI development; the CLI loads .env by default from the repo root.
RADROOTS_CLI_PATHS_PROFILE=repo_local
RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=infra/local/runtime/radroots
-RADROOTS_CLI_OUTPUT_FORMAT=human
+RADROOTS_CLI_OUTPUT_FORMAT=terminal
RADROOTS_CLI_LOGGING_FILTER=info
RADROOTS_CLI_LOGGING_OUTPUT_DIR=infra/local/runtime/radroots/logs/apps/cli
RADROOTS_CLI_LOGGING_STDOUT=false
diff --git a/src/cli/global.rs b/src/cli/global.rs
@@ -4,7 +4,7 @@ use crate::runtime::config::OutputFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RuntimeOutputFormatArg {
- Human,
+ Terminal,
Json,
Ndjson,
}
@@ -12,7 +12,7 @@ pub enum RuntimeOutputFormatArg {
impl RuntimeOutputFormatArg {
pub fn as_output_format(self) -> OutputFormat {
match self {
- Self::Human => OutputFormat::Human,
+ Self::Terminal => OutputFormat::Terminal,
Self::Json => OutputFormat::Json,
Self::Ndjson => OutputFormat::Ndjson,
}
diff --git a/src/cli/input.rs b/src/cli/input.rs
@@ -5,8 +5,8 @@ use serde_json::Value;
pub fn runtime_invocation_args_from_target(args: &TargetCliArgs) -> RuntimeInvocationArgs {
RuntimeInvocationArgs {
- output_format: Some(match args.format {
- TargetOutputFormat::Human => RuntimeOutputFormatArg::Human,
+ output_format: args.format.map(|format| match format {
+ TargetOutputFormat::Terminal => RuntimeOutputFormatArg::Terminal,
TargetOutputFormat::Json => RuntimeOutputFormatArg::Json,
TargetOutputFormat::Ndjson => RuntimeOutputFormatArg::Ndjson,
}),
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
@@ -37,7 +37,7 @@ use clap::{ArgAction, Parser, Subcommand, ValueEnum};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum TargetOutputFormat {
- Human,
+ Terminal,
Json,
Ndjson,
}
@@ -67,8 +67,8 @@ impl TargetPublishTransport {
disable_help_subcommand = true
)]
pub struct TargetCliArgs {
- #[arg(long = "format", global = true, value_enum, default_value = "human")]
- pub format: TargetOutputFormat,
+ #[arg(long = "format", global = true, value_enum)]
+ pub format: Option<TargetOutputFormat>,
#[arg(long = "account-id", global = true)]
pub account_id: Option<String>,
#[arg(long = "relay", global = true)]
@@ -373,7 +373,7 @@ mod tests {
])
.expect("target args parse");
- assert_eq!(parsed.format, TargetOutputFormat::Ndjson);
+ assert_eq!(parsed.format, Some(TargetOutputFormat::Ndjson));
assert_eq!(parsed.account_id.as_deref(), Some("acct_test"));
assert_eq!(
parsed.relay,
diff --git a/src/main.rs b/src/main.rs
@@ -7,7 +7,7 @@ mod registry;
mod runtime;
mod view;
-use std::io::Write;
+use std::io::{IsTerminal, Write};
use std::process::ExitCode;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -27,9 +27,20 @@ use crate::ops::{
OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService,
TargetOperationRequest,
};
-use crate::out::envelope::OutputEnvelope;
+use crate::out::envelope::{CliExitCode, OutputEnvelope, OutputError};
+use crate::out::terminal::actions::terminal_actions_from_next_actions;
+use crate::out::terminal::errors::terminal_error_document;
+use crate::out::terminal::layout::{
+ TerminalDocument, TerminalField, TerminalHeader, TerminalReference, TerminalSymbol,
+};
+use crate::out::terminal::renderer::{
+ TerminalColorPolicy, TerminalRenderContext, TerminalVerbosity, render_terminal_document,
+};
+use crate::out::terminal::values::{proof_summary, string_path, transport_label};
use crate::registry::{NetworkRequirement, network_requirement, requires_local_signer_mode};
-use crate::runtime::config::{RuntimeConfig, SignerBackend};
+use crate::runtime::config::{
+ OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, Verbosity,
+};
use crate::runtime::logging::initialize_logging;
static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0);
@@ -48,20 +59,38 @@ fn run() -> Result<ExitCode, runtime::RuntimeError> {
debug_assert!(registry::registry_linkage_is_valid());
debug_assert!(ops::adapter_registry_linkage_is_valid());
let args = TargetCliArgs::parse();
- let request =
+ let mut request =
TargetOperationRequest::from_target_args(&args).map_err(operation_config_error)?;
+ let pre_runtime_render_config =
+ 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, args.format)?;
+ render_envelope(&envelope, &pre_runtime_render_config)?;
return Ok(envelope_exit_code(&envelope));
}
- let config = RuntimeConfig::from_system(&runtime_invocation_args_from_target(&args))?;
- let logging = initialize_logging(&config.logging)?;
+ 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));
+ }
+ };
+ request.set_output_format(OperationOutputFormat::from(config.output.format));
+ let runtime_render_config = render_config_from_runtime(&config);
+ let logging = match initialize_logging(&config.logging) {
+ 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));
+ }
+ };
let envelope = match validate_request_contract(&request, &config) {
Ok(()) => execute_request(request, &config, &logging),
Err(error) => failure_envelope(&request, error),
};
- render_envelope(&envelope, args.format)?;
+ render_envelope(&envelope, &runtime_render_config)?;
Ok(envelope_exit_code(&envelope))
}
@@ -469,6 +498,23 @@ fn failure_envelope(
)
}
+fn runtime_config_failure_envelope(
+ request: &TargetOperationRequest,
+ error: runtime::RuntimeError,
+) -> OutputEnvelope {
+ OutputEnvelope::failure(
+ request.operation_id(),
+ OutputError::new(
+ "invalid_input",
+ error.to_string(),
+ CliExitCode::InvalidInput,
+ ),
+ request
+ .context()
+ .envelope_context(next_request_id(request.operation_id())),
+ )
+}
+
fn next_request_id(operation_id: &str) -> String {
let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
@@ -484,81 +530,217 @@ fn next_request_id(operation_id: &str) -> String {
)
}
+#[derive(Debug, Clone)]
+struct EnvelopeRenderConfig {
+ format: RenderOutputFormat,
+ terminal: TerminalRenderContext,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RenderOutputFormat {
+ Terminal,
+ Json,
+ Ndjson,
+}
+
+fn render_config_from_target_args(
+ args: &TargetCliArgs,
+ format: TargetOutputFormat,
+) -> EnvelopeRenderConfig {
+ EnvelopeRenderConfig {
+ format: match format {
+ TargetOutputFormat::Terminal => RenderOutputFormat::Terminal,
+ TargetOutputFormat::Json => RenderOutputFormat::Json,
+ TargetOutputFormat::Ndjson => RenderOutputFormat::Ndjson,
+ },
+ terminal: TerminalRenderContext {
+ verbosity: terminal_verbosity_from_flags(args.quiet, args.verbose, args.trace),
+ color: terminal_color_policy(!args.no_color),
+ width: 80,
+ stdout_is_tty: std::io::stdout().is_terminal(),
+ stderr_is_tty: std::io::stderr().is_terminal(),
+ dry_run: args.dry_run,
+ },
+ }
+}
+
+fn render_config_from_runtime(config: &RuntimeConfig) -> EnvelopeRenderConfig {
+ EnvelopeRenderConfig {
+ format: match config.output.format {
+ RuntimeOutputFormat::Terminal => RenderOutputFormat::Terminal,
+ RuntimeOutputFormat::Json => RenderOutputFormat::Json,
+ RuntimeOutputFormat::Ndjson => RenderOutputFormat::Ndjson,
+ },
+ terminal: TerminalRenderContext {
+ verbosity: terminal_verbosity_from_runtime(config.output.verbosity),
+ color: terminal_color_policy(config.output.color),
+ width: 80,
+ stdout_is_tty: config.interaction.stdout_tty,
+ stderr_is_tty: std::io::stderr().is_terminal(),
+ dry_run: config.output.dry_run,
+ },
+ }
+}
+
+fn terminal_verbosity_from_flags(quiet: bool, verbose: bool, trace: bool) -> TerminalVerbosity {
+ if trace {
+ TerminalVerbosity::Trace
+ } else if verbose {
+ TerminalVerbosity::Verbose
+ } else if quiet {
+ TerminalVerbosity::Quiet
+ } else {
+ TerminalVerbosity::Normal
+ }
+}
+
+fn terminal_verbosity_from_runtime(verbosity: Verbosity) -> TerminalVerbosity {
+ match verbosity {
+ Verbosity::Quiet => TerminalVerbosity::Quiet,
+ Verbosity::Normal => TerminalVerbosity::Normal,
+ Verbosity::Verbose => TerminalVerbosity::Verbose,
+ Verbosity::Trace => TerminalVerbosity::Trace,
+ }
+}
+
+fn terminal_color_policy(color: bool) -> TerminalColorPolicy {
+ if color {
+ TerminalColorPolicy::Auto
+ } else {
+ TerminalColorPolicy::Never
+ }
+}
+
fn render_envelope(
envelope: &OutputEnvelope,
- format: TargetOutputFormat,
+ config: &EnvelopeRenderConfig,
) -> Result<(), runtime::RuntimeError> {
- let stdout = std::io::stdout();
- let mut handle = stdout.lock();
- match format {
- TargetOutputFormat::Human => {
- render_human_envelope(&mut handle, envelope)?;
- }
- TargetOutputFormat::Json => {
+ match config.format {
+ RenderOutputFormat::Terminal => render_terminal_envelope(envelope, &config.terminal),
+ RenderOutputFormat::Json => {
+ let stdout = std::io::stdout();
+ let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, envelope)?;
+ writeln!(handle)?;
+ Ok(())
}
- TargetOutputFormat::Ndjson => {
+ RenderOutputFormat::Ndjson => {
+ let stdout = std::io::stdout();
+ let mut handle = stdout.lock();
for frame in envelope.to_ndjson_frames() {
serde_json::to_writer(&mut handle, &frame)?;
writeln!(handle)?;
}
- return Ok(());
+ Ok(())
}
}
- writeln!(handle)?;
- Ok(())
}
-fn render_human_envelope(
- handle: &mut impl Write,
+fn render_terminal_envelope(
envelope: &OutputEnvelope,
+ cx: &TerminalRenderContext,
) -> Result<(), runtime::RuntimeError> {
- writeln!(
- handle,
- "{}: {}",
- envelope.operation_id,
- human_envelope_status(envelope)
- )?;
- writeln!(handle, "request_id: {}", envelope.request_id)?;
- if let Some(error) = envelope.errors.first() {
- writeln!(handle, "error: {}", error.code)?;
- writeln!(handle, "message: {}", error.message)?;
+ let rendered = render_terminal_document(&terminal_document_from_envelope(envelope), cx);
+ if envelope.errors.is_empty() {
+ let stdout = std::io::stdout();
+ let mut handle = stdout.lock();
+ writeln!(handle, "{rendered}")?;
+ } else {
+ let stderr = std::io::stderr();
+ let mut handle = stderr.lock();
+ writeln!(handle, "{rendered}")?;
}
- let display = human_display_source(envelope);
- if !envelope.errors.is_empty()
- && let Some(state) = human_state(display)
- {
- writeln!(handle, "state: {state}")?;
+ Ok(())
+}
+
+fn terminal_document_from_envelope(envelope: &OutputEnvelope) -> TerminalDocument {
+ let display = terminal_display_source(envelope);
+ let mut document = if envelope.errors.is_empty() {
+ let status = terminal_envelope_status(envelope);
+ TerminalDocument::new(TerminalHeader::new(
+ terminal_status_symbol(status, envelope.dry_run),
+ terminal_title(envelope.operation_id.as_str(), status),
+ ))
+ } else {
+ let mut document = terminal_error_document(envelope);
+ add_terminal_display_fields(&mut document, display, false);
+ document
+ };
+ if envelope.errors.is_empty() {
+ add_terminal_display_fields(&mut document, display, true);
}
- if let Some(mode) = human_publish_transport(display) {
- writeln!(handle, "publish_transport: {mode}")?;
+ document.warnings = envelope
+ .warnings
+ .iter()
+ .map(|warning| {
+ crate::out::terminal::layout::TerminalWarning::new(
+ warning.code.clone(),
+ warning.message.clone(),
+ )
+ })
+ .collect();
+ document.next = terminal_actions_from_next_actions(&envelope.next_actions);
+ document.reference = terminal_reference(envelope);
+ document
+}
+
+fn add_terminal_display_fields(
+ document: &mut TerminalDocument,
+ display: &Value,
+ include_reason: bool,
+) {
+ if let Some(state) = terminal_state(display) {
+ push_terminal_field(document, "State", terminal_status_label(state));
}
- if let Some(state) = human_publish_state(display) {
- writeln!(handle, "publish_state: {state}")?;
+ if let Some(mode) = terminal_publish_transport(display) {
+ push_terminal_field(document, "Transport", transport_label(mode));
}
- if let Some(state) = human_proof_state(display) {
- writeln!(handle, "proof_state: {state}")?;
+ if let Some(state) = terminal_publish_state(display) {
+ push_terminal_field(document, "Publish", terminal_status_label(state));
}
- if let Some(system) = human_proof_system(display) {
- writeln!(handle, "proof_system: {system}")?;
+ if let Some(proof) = proof_summary(display) {
+ push_terminal_field(document, "Proof", proof);
}
- if let Some(verified) = human_cryptographic_proof_verified(display) {
- writeln!(handle, "cryptographic_proof_verified: {verified}")?;
+ if include_reason && let Some(reason) = terminal_reason(display) {
+ push_terminal_field(document, "Reason", reason.to_owned());
}
- if let Some(reason) = human_reason(display) {
- writeln!(handle, "reason: {reason}")?;
+}
+
+fn push_terminal_field(
+ document: &mut TerminalDocument,
+ label: impl Into<String>,
+ value: impl Into<String>,
+) {
+ let label = label.into();
+ let value = value.into();
+ if value.trim().is_empty() {
+ return;
}
- let actions = human_actions(envelope, display);
- if !actions.is_empty() {
- writeln!(handle, "next:")?;
- for action in actions {
- writeln!(handle, "- {action}")?;
- }
+ if document
+ .fields
+ .iter()
+ .any(|field| field.label == label && field.value == value)
+ {
+ return;
}
- Ok(())
+ document.fields.push(TerminalField::new(label, value));
}
-fn human_display_source(envelope: &OutputEnvelope) -> &Value {
+fn terminal_reference(envelope: &OutputEnvelope) -> Option<TerminalReference> {
+ let reference = TerminalReference {
+ request_id: Some(envelope.request_id.clone()),
+ correlation_id: envelope.correlation_id.clone(),
+ idempotency_key: envelope.idempotency_key.clone(),
+ event_id: None,
+ event_addr: None,
+ job_id: None,
+ path: None,
+ source: None,
+ };
+ (!reference.is_empty()).then_some(reference)
+}
+
+fn terminal_display_source(envelope: &OutputEnvelope) -> &Value {
if !envelope.result.is_null() {
return &envelope.result;
}
@@ -569,96 +751,32 @@ fn human_display_source(envelope: &OutputEnvelope) -> &Value {
.unwrap_or(&envelope.result)
}
-fn human_state(result: &Value) -> Option<&str> {
- human_string_path(result, &["state"])
-}
-
-fn human_publish_transport(result: &Value) -> Option<&str> {
- human_string_path(result, &["publish", "mode"])
- .or_else(|| human_string_path(result, &["checks", "publish", "mode"]))
- .or_else(|| human_string_path(result, &["publish_transport"]))
-}
-
-fn human_publish_state(result: &Value) -> Option<&str> {
- human_string_path(result, &["publish", "state"])
- .or_else(|| human_string_path(result, &["checks", "publish", "state"]))
- .or_else(|| human_string_path(result, &["publish_state"]))
+fn terminal_state(result: &Value) -> Option<&str> {
+ string_path(result, &["state"])
}
-fn human_proof_state(result: &Value) -> Option<&str> {
- human_string_path(result, &["proof_verification", "state"])
- .or_else(|| human_string_path(result, &["proof_verification_state"]))
-}
-
-fn human_proof_system(result: &Value) -> Option<&str> {
- human_string_path(result, &["proof_verification", "proof_system"])
- .or_else(|| human_string_path(result, &["receipt", "proof", "system"]))
- .or_else(|| human_string_path(result, &["proof_system"]))
-}
-
-fn human_cryptographic_proof_verified(result: &Value) -> Option<bool> {
- human_bool_path(
- result,
- &["proof_verification", "cryptographic_proof_verified"],
- )
-}
-
-fn human_reason(result: &Value) -> Option<&str> {
- human_string_path(result, &["reason"])
- .or_else(|| human_string_path(result, &["publish", "reason"]))
- .or_else(|| human_string_path(result, &["checks", "publish", "reason"]))
- .or_else(|| human_string_path(result, &["store", "reason"]))
- .or_else(|| human_string_path(result, &["checks", "store", "reason"]))
- .or_else(|| human_string_path(result, &["checks", "account", "reason"]))
-}
-
-fn human_actions(envelope: &OutputEnvelope, display: &Value) -> Vec<String> {
- let mut actions = display
- .get("actions")
- .and_then(Value::as_array)
- .into_iter()
- .flatten()
- .filter_map(Value::as_str)
- .map(str::to_owned)
- .collect::<Vec<_>>();
- if actions.is_empty() {
- actions = envelope
- .next_actions
- .iter()
- .map(|action| {
- action
- .command
- .clone()
- .or_else(|| action.description.clone())
- .unwrap_or_else(|| action.label.clone())
- })
- .collect();
- }
- actions.into_iter().fold(Vec::new(), |mut unique, action| {
- if !unique.contains(&action) {
- unique.push(action);
- }
- unique
- })
+fn terminal_publish_transport(result: &Value) -> Option<&str> {
+ string_path(result, &["publish", "mode"])
+ .or_else(|| string_path(result, &["checks", "publish", "mode"]))
+ .or_else(|| string_path(result, &["publish_transport"]))
}
-fn human_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())
+fn terminal_publish_state(result: &Value) -> Option<&str> {
+ string_path(result, &["publish", "state"])
+ .or_else(|| string_path(result, &["checks", "publish", "state"]))
+ .or_else(|| string_path(result, &["publish_state"]))
}
-fn human_bool_path(value: &Value, path: &[&str]) -> Option<bool> {
- let mut current = value;
- for segment in path {
- current = current.get(*segment)?;
- }
- current.as_bool()
+fn terminal_reason(result: &Value) -> Option<&str> {
+ string_path(result, &["reason"])
+ .or_else(|| string_path(result, &["publish", "reason"]))
+ .or_else(|| string_path(result, &["checks", "publish", "reason"]))
+ .or_else(|| string_path(result, &["store", "reason"]))
+ .or_else(|| string_path(result, &["checks", "store", "reason"]))
+ .or_else(|| string_path(result, &["checks", "account", "reason"]))
}
-fn human_envelope_status(envelope: &OutputEnvelope) -> &str {
+fn terminal_envelope_status(envelope: &OutputEnvelope) -> &str {
if !envelope.errors.is_empty() {
return "error";
}
@@ -675,6 +793,73 @@ fn human_envelope_status(envelope: &OutputEnvelope) -> &str {
"ok"
}
+fn terminal_status_symbol(status: &str, dry_run: bool) -> TerminalSymbol {
+ if dry_run || status == "dry_run" {
+ return TerminalSymbol::Neutral;
+ }
+ if terminal_status_needs_attention(status) {
+ TerminalSymbol::Attention
+ } else {
+ TerminalSymbol::Success
+ }
+}
+
+fn terminal_status_needs_attention(status: &str) -> bool {
+ matches!(
+ status,
+ "needs_attention"
+ | "unconfigured"
+ | "unavailable"
+ | "blocked"
+ | "invalid"
+ | "not_ready"
+ | "partial"
+ | "degraded"
+ | "failed"
+ | "conflict"
+ )
+}
+
+fn terminal_title(operation_id: &str, status: &str) -> String {
+ format!(
+ "{} {}",
+ operation_title(operation_id),
+ terminal_status_label(status)
+ )
+}
+
+fn operation_title(operation_id: &str) -> String {
+ let mut words = operation_id
+ .split('.')
+ .flat_map(|part| part.split('_'))
+ .filter(|part| !part.is_empty())
+ .map(str::to_owned)
+ .collect::<Vec<_>>();
+ if let Some(first) = words.first_mut() {
+ *first = capitalize_ascii_word(first);
+ }
+ words.join(" ")
+}
+
+fn terminal_status_label(status: &str) -> String {
+ status
+ .split('_')
+ .filter(|part| !part.is_empty())
+ .collect::<Vec<_>>()
+ .join(" ")
+}
+
+fn capitalize_ascii_word(word: &str) -> String {
+ let mut chars = word.chars();
+ let Some(first) = chars.next() else {
+ return String::new();
+ };
+ let mut rendered = String::new();
+ rendered.push(first.to_ascii_uppercase());
+ rendered.extend(chars);
+ rendered
+}
+
fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode {
envelope
.errors
diff --git a/src/ops/context.rs b/src/ops/context.rs
@@ -1,29 +1,40 @@
use crate::cli::{TargetCliArgs, TargetOutputFormat};
-use crate::out::envelope::{EnvelopeActor, EnvelopeContext, OutputFormat};
+use crate::out::envelope::{EnvelopeActor, EnvelopeContext, OutputFormat as EnvelopeOutputFormat};
+use crate::runtime::config::OutputFormat as RuntimeOutputFormat;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationOutputFormat {
- Human,
+ Terminal,
Json,
Ndjson,
}
impl Default for OperationOutputFormat {
fn default() -> Self {
- Self::Human
+ Self::Terminal
}
}
impl From<TargetOutputFormat> for OperationOutputFormat {
fn from(format: TargetOutputFormat) -> Self {
match format {
- TargetOutputFormat::Human => Self::Human,
+ TargetOutputFormat::Terminal => Self::Terminal,
TargetOutputFormat::Json => Self::Json,
TargetOutputFormat::Ndjson => Self::Ndjson,
}
}
}
+impl From<RuntimeOutputFormat> for OperationOutputFormat {
+ fn from(format: RuntimeOutputFormat) -> Self {
+ match format {
+ RuntimeOutputFormat::Terminal => Self::Terminal,
+ RuntimeOutputFormat::Json => Self::Json,
+ RuntimeOutputFormat::Ndjson => Self::Ndjson,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OperationNetworkMode {
Default,
@@ -69,7 +80,10 @@ pub struct OperationContext {
impl OperationContext {
pub fn from_target_args(args: &TargetCliArgs) -> Self {
Self {
- output_format: OperationOutputFormat::from(args.format),
+ output_format: args
+ .format
+ .map(OperationOutputFormat::from)
+ .unwrap_or_default(),
account_id: args.account_id.clone(),
relays: args.relay.clone(),
network_mode: if args.offline {
@@ -98,9 +112,9 @@ impl OperationContext {
pub fn envelope_context(&self, request_id: impl Into<String>) -> EnvelopeContext {
let mut context = EnvelopeContext::new(request_id, self.dry_run);
context.output_format = match self.output_format {
- OperationOutputFormat::Human => OutputFormat::Human,
- OperationOutputFormat::Json => OutputFormat::Json,
- OperationOutputFormat::Ndjson => OutputFormat::Ndjson,
+ OperationOutputFormat::Terminal => EnvelopeOutputFormat::Terminal,
+ OperationOutputFormat::Json => EnvelopeOutputFormat::Json,
+ OperationOutputFormat::Ndjson => EnvelopeOutputFormat::Ndjson,
};
context.correlation_id = self.correlation_id.clone();
context.idempotency_key = self.idempotency_key.clone();
diff --git a/src/ops/exec/basket.rs b/src/ops/exec/basket.rs
@@ -1853,7 +1853,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs
@@ -1318,7 +1318,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/farm.rs b/src/ops/exec/farm.rs
@@ -843,7 +843,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs
@@ -554,7 +554,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/market.rs b/src/ops/exec/market.rs
@@ -618,7 +618,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/runtime.rs b/src/ops/exec/runtime.rs
@@ -298,7 +298,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/exec/trade.rs b/src/ops/exec/trade.rs
@@ -1740,7 +1740,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/ops/target.rs b/src/ops/target.rs
@@ -1,7 +1,7 @@
use serde::Serialize;
use serde_json::Value;
-use super::context::OperationContext;
+use super::context::{OperationContext, OperationOutputFormat};
use super::error::OperationAdapterError;
use super::request::{
OperationData, OperationRequest, OperationRequestData, OperationRequestPayload,
@@ -62,6 +62,12 @@ macro_rules! target_operation_contracts {
}
}
+ pub fn set_output_format(&mut self, output_format: OperationOutputFormat) {
+ match self {
+ $( Self::$variant(request) => request.context.output_format = output_format, )+
+ }
+ }
+
pub fn request_type_name(&self) -> &'static str {
match self {
$( Self::$variant(request) => request.request_type_name(), )+
diff --git a/src/out/envelope.rs b/src/out/envelope.rs
@@ -21,7 +21,7 @@ impl EnvelopeContext {
request_id: request_id.into(),
correlation_id: None,
idempotency_key: None,
- output_format: OutputFormat::Human,
+ output_format: OutputFormat::Terminal,
dry_run,
actor: None,
}
@@ -363,7 +363,7 @@ pub enum OutputStatus {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputFormat {
- Human,
+ Terminal,
Json,
Ndjson,
}
@@ -529,7 +529,7 @@ mod tests {
assert_eq!(value["operation_id"], "listing.publish");
assert_eq!(value["kind"], "listing.publish");
assert_eq!(value["status"], "ok");
- assert_eq!(value["output_format"], "human");
+ assert_eq!(value["output_format"], "terminal");
assert_eq!(value["request_id"], "req_test");
assert_eq!(value["correlation_id"], "corr_test");
assert_eq!(value["idempotency_key"], "idem_test");
@@ -712,7 +712,7 @@ mod tests {
let frames = envelope.to_ndjson_frames();
assert_eq!(frames[0].payload["status"], "error");
- assert_eq!(frames[0].payload["output_format"], "human");
+ assert_eq!(frames[0].payload["output_format"], "terminal");
assert_eq!(frames[1].payload["status"], "error");
assert_eq!(frames[1].payload["reason_code"], "not_implemented");
assert_eq!(frames[1].payload["resource"]["kind"], "trade");
diff --git a/src/runtime/config.rs b/src/runtime/config.rs
@@ -81,7 +81,7 @@ const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
- Human,
+ Terminal,
Json,
Ndjson,
}
@@ -89,7 +89,7 @@ pub enum OutputFormat {
impl OutputFormat {
pub fn as_str(self) -> &'static str {
match self {
- Self::Human => "human",
+ Self::Terminal => "terminal",
Self::Json => "json",
Self::Ndjson => "ndjson",
}
@@ -1585,7 +1585,7 @@ fn resolve_output_format(
.and_then(|output| output.format.as_deref())
{
Some(value) => parse_output_format(value),
- None => Ok(OutputFormat::Human),
+ None => Ok(OutputFormat::Terminal),
}
}
@@ -1706,7 +1706,7 @@ fn validate_logging_output_contract(
output: &OutputConfig,
logging: &LoggingConfig,
) -> Result<(), RuntimeError> {
- if logging.stdout && matches!(output.format, OutputFormat::Json | OutputFormat::Ndjson) {
+ if logging.stdout {
return Err(RuntimeError::Config(format!(
"stdout logging cannot be used with {} output; unset {ENV_CLI_LOG_STDOUT} or use --no-log-stdout",
output.format.as_str()
@@ -1781,11 +1781,11 @@ fn parse_env_file_values(raw: &str, path: &Path) -> Result<EnvFileValues, Runtim
fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> {
match value.trim().to_ascii_lowercase().as_str() {
- "human" => Ok(OutputFormat::Human),
+ "terminal" => Ok(OutputFormat::Terminal),
"json" => Ok(OutputFormat::Json),
"ndjson" => Ok(OutputFormat::Ndjson),
other => Err(RuntimeError::Config(format!(
- "{ENV_CLI_OUTPUT_FORMAT} must be `human`, `json`, or `ndjson`, got `{other}`"
+ "{ENV_CLI_OUTPUT_FORMAT} must be `terminal`, `json`, or `ndjson`, got `{other}`"
))),
}
}
@@ -2015,12 +2015,12 @@ mod tests {
#[test]
fn flags_override_environment_values() {
let args = RuntimeInvocationArgs {
- output_format: Some(RuntimeOutputFormatArg::Human),
+ output_format: Some(RuntimeOutputFormatArg::Terminal),
verbose: true,
dry_run: true,
no_color: true,
log_filter: Some("debug".to_owned()),
- log_stdout: true,
+ log_stdout: false,
identity_path: Some(PathBuf::from("custom-identity.json")),
signer: Some("local".to_owned()),
publish_transport: Some("direct_nostr_relay".to_owned()),
@@ -2032,7 +2032,10 @@ mod tests {
..runtime_args()
};
let env = MapEnvironment::new(BTreeMap::from([
- ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "human".to_owned()),
+ (
+ "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(),
+ "terminal".to_owned(),
+ ),
("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "trace".to_owned()),
("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()),
(
@@ -2068,7 +2071,7 @@ mod tests {
assert_eq!(
resolved.output,
OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Verbose,
color: false,
dry_run: true,
@@ -2117,7 +2120,7 @@ mod tests {
}
);
assert_eq!(resolved.logging.filter, "debug");
- assert!(resolved.logging.stdout);
+ assert!(!resolved.logging.stdout);
assert_eq!(
resolved.identity.path,
PathBuf::from("custom-identity.json")
@@ -2330,7 +2333,7 @@ mod tests {
let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
.expect("resolve runtime config");
- assert_eq!(resolved.output.format, OutputFormat::Human);
+ assert_eq!(resolved.output.format, OutputFormat::Terminal);
assert_eq!(resolved.logging.filter, DEFAULT_LOG_FILTER);
assert!(!resolved.logging.stdout);
assert_eq!(resolved.account.selector, None);
@@ -2490,9 +2493,22 @@ path = "identity/from-toml.json"
}
#[test]
- fn machine_output_rejects_stdout_logging_flags() {
+ fn output_rejects_stdout_logging_flags() {
let env = MapEnvironment::new(BTreeMap::new());
+ let terminal_args = RuntimeInvocationArgs {
+ output_format: Some(RuntimeOutputFormatArg::Terminal),
+ log_stdout: true,
+ ..runtime_args()
+ };
+ let error =
+ RuntimeConfig::resolve_with_env_file(&terminal_args, &env, &EnvFileValues::default())
+ .expect_err("terminal stdout logging should fail");
+ let message = error.to_string();
+ assert!(message.contains("stdout logging"));
+ assert!(message.contains("terminal output"));
+ assert!(message.contains("--no-log-stdout"));
+
let json_args = RuntimeInvocationArgs {
json: true,
log_stdout: true,
@@ -2520,7 +2536,17 @@ path = "identity/from-toml.json"
}
#[test]
- fn machine_output_rejects_stdout_logging_environment() {
+ fn output_rejects_stdout_logging_environment() {
+ let terminal_args = runtime_args();
+ let env = MapEnvironment::new(BTreeMap::from([(
+ "RADROOTS_CLI_LOGGING_STDOUT".to_owned(),
+ "true".to_owned(),
+ )]));
+ let error =
+ RuntimeConfig::resolve_with_env_file(&terminal_args, &env, &EnvFileValues::default())
+ .expect_err("terminal stdout logging from env should fail");
+ assert!(error.to_string().contains("terminal output"));
+
let json_args = RuntimeInvocationArgs {
json: true,
..runtime_args()
@@ -2548,7 +2574,7 @@ path = "identity/from-toml.json"
}
#[test]
- fn no_log_stdout_overrides_environment_for_machine_output() {
+ fn no_log_stdout_overrides_environment_for_output() {
let args = RuntimeInvocationArgs {
json: true,
no_log_stdout: true,
@@ -2560,7 +2586,7 @@ path = "identity/from-toml.json"
)]));
let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default())
- .expect("resolve machine output with stdout logging disabled");
+ .expect("resolve output with stdout logging disabled");
assert_eq!(resolved.output.format, OutputFormat::Json);
assert!(!resolved.logging.stdout);
}
@@ -2729,12 +2755,12 @@ RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd
let args = runtime_args();
let env = MapEnvironment::new(BTreeMap::from([
("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "info".to_owned()),
- ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()),
+ ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()),
]));
let env_file = parse_env_file_values(
r#"
RADROOTS_CLI_LOGGING_FILTER=debug
-RADROOTS_CLI_LOGGING_STDOUT=false
+RADROOTS_CLI_LOGGING_STDOUT=true
"#,
Path::new(".env.test"),
)
@@ -2742,9 +2768,9 @@ RADROOTS_CLI_LOGGING_STDOUT=false
let resolved =
RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config");
- assert_eq!(resolved.output.format, OutputFormat::Human);
+ assert_eq!(resolved.output.format, OutputFormat::Terminal);
assert_eq!(resolved.logging.filter, "info");
- assert!(resolved.logging.stdout);
+ assert!(!resolved.logging.stdout);
}
#[test]
@@ -3555,7 +3581,7 @@ RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev
#[test]
fn duplicate_env_file_variable_fails() {
let error = parse_env_file_values(
- "RADROOTS_CLI_OUTPUT_FORMAT=json\nRADROOTS_CLI_OUTPUT_FORMAT=human\n",
+ "RADROOTS_CLI_OUTPUT_FORMAT=json\nRADROOTS_CLI_OUTPUT_FORMAT=terminal\n",
Path::new(".env.test"),
)
.expect_err("duplicate env variable should fail");
diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs
@@ -247,8 +247,8 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView,
return Ok(FarmSetView {
state: "unconfigured".to_owned(),
source: FARM_CONFIG_SOURCE.to_owned(),
- field: human_field_name(args.field).to_owned(),
- value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(),
+ field: terminal_field_name(args.field).to_owned(),
+ value: terminal_field_value(args.field, args.value.join(" ").trim()).to_owned(),
config: None,
reason: Some(format!("no farm draft found at {}", path.display())),
actions: vec!["radroots farm create".to_owned()],
@@ -281,8 +281,8 @@ pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView,
Ok(FarmSetView {
state: "updated".to_owned(),
source: FARM_CONFIG_SOURCE.to_owned(),
- field: human_field_name(args.field).to_owned(),
- value: human_field_value(args.field, field_value.as_str()).to_owned(),
+ field: terminal_field_name(args.field).to_owned(),
+ value: terminal_field_value(args.field, field_value.as_str()).to_owned(),
config: Some(summary_view(
resolved.scope,
written_path.display().to_string(),
@@ -305,8 +305,8 @@ pub fn set_preflight(
return Ok(FarmSetView {
state: "unconfigured".to_owned(),
source: FARM_CONFIG_SOURCE.to_owned(),
- field: human_field_name(args.field).to_owned(),
- value: human_field_value(args.field, args.value.join(" ").trim()).to_owned(),
+ field: terminal_field_name(args.field).to_owned(),
+ value: terminal_field_value(args.field, args.value.join(" ").trim()).to_owned(),
config: None,
reason: Some(format!("no farm draft found at {}", path.display())),
actions: vec!["radroots farm create".to_owned()],
@@ -332,8 +332,8 @@ pub fn set_preflight(
Ok(FarmSetView {
state: "dry_run".to_owned(),
source: FARM_CONFIG_SOURCE.to_owned(),
- field: human_field_name(args.field).to_owned(),
- value: human_field_value(args.field, field_value.as_str()).to_owned(),
+ field: terminal_field_name(args.field).to_owned(),
+ value: terminal_field_value(args.field, field_value.as_str()).to_owned(),
config: Some(summary_view(
resolved.scope,
path.display().to_string(),
@@ -1854,7 +1854,7 @@ fn push_action(actions: &mut Vec<String>, action: &str) {
}
}
-fn human_field_name(field: FarmFieldArg) -> &'static str {
+fn terminal_field_name(field: FarmFieldArg) -> &'static str {
match field {
FarmFieldArg::Name => "Name",
FarmFieldArg::DisplayName => "Display name",
@@ -1871,9 +1871,9 @@ fn human_field_name(field: FarmFieldArg) -> &'static str {
}
}
-fn human_field_value(field: FarmFieldArg, value: &str) -> String {
+fn terminal_field_value(field: FarmFieldArg, value: &str) -> String {
match field {
- FarmFieldArg::Delivery => humanize_delivery_method(value),
+ FarmFieldArg::Delivery => display_delivery_method(value),
_ => value.to_owned(),
}
}
@@ -2178,7 +2178,7 @@ fn resolved_delivery_method(document: &FarmConfigDocument) -> Option<String> {
non_empty(document.listing_defaults.delivery_method.as_str())
}
-fn humanize_delivery_method(value: &str) -> String {
+fn display_delivery_method(value: &str) -> String {
value
.split('_')
.filter(|segment| !segment.is_empty())
diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs
@@ -273,7 +273,7 @@ mod tests {
fn sample_config(bindings: Vec<CapabilityBindingConfig>, hyf_enabled: bool) -> RuntimeConfig {
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs
@@ -2221,7 +2221,7 @@ mod tests {
let secrets = root.join("secrets");
RuntimeConfig {
output: OutputConfig {
- format: OutputFormat::Human,
+ format: OutputFormat::Terminal,
verbosity: Verbosity::Normal,
color: true,
dry_run: false,
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -2784,7 +2784,7 @@ fn next_actions_mirror_result_actions_for_json_and_ndjson() {
}
#[test]
-fn default_human_output_is_concise_and_not_json() {
+fn default_terminal_output_is_concise_and_not_json() {
let output = radroots()
.args(["workspace", "get"])
.output()
@@ -2792,74 +2792,141 @@ fn default_human_output_is_concise_and_not_json() {
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
- assert!(stdout.starts_with("workspace.get: ok\n"));
- assert!(stdout.contains("request_id: req_workspace_get_"));
+ assert!(stdout.starts_with("✓ Workspace get ok\n"));
+ assert!(stderr.is_empty());
assert!(serde_json::from_str::<Value>(&stdout).is_err());
}
#[test]
-fn human_health_status_surfaces_publish_reason_and_actions() {
+fn explicit_terminal_output_is_accepted() {
+ let output = radroots()
+ .args(["--format", "terminal", "workspace", "get"])
+ .output()
+ .expect("run terminal workspace get");
+
+ assert!(output.status.success());
+ let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
+
+ assert!(stdout.starts_with("✓ Workspace get ok\n"));
+ assert!(stderr.is_empty());
+ assert!(serde_json::from_str::<Value>(&stdout).is_err());
+}
+
+#[test]
+fn human_output_format_is_rejected() {
+ let output = radroots()
+ .args(["--format", "human", "workspace", "get"])
+ .output()
+ .expect("run human format rejection");
+
+ assert_eq!(output.status.code(), Some(2));
+ assert!(output.stdout.is_empty());
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
+
+ assert!(stderr.contains("invalid value 'human'"));
+ assert!(stderr.contains("terminal"));
+ assert!(stderr.contains("json"));
+ assert!(stderr.contains("ndjson"));
+}
+
+#[test]
+fn environment_output_format_controls_default_rendering() {
+ let sandbox = RadrootsCliSandbox::new();
+ let output = sandbox
+ .command()
+ .env("RADROOTS_CLI_OUTPUT_FORMAT", "json")
+ .args(["workspace", "get"])
+ .output()
+ .expect("run env json workspace get");
+
+ assert!(output.status.success());
+ let value = json_from_stdout(&output);
+ assert_eq!(value["operation_id"], "workspace.get");
+ assert_eq!(value["output_format"], "json");
+}
+
+#[test]
+fn environment_human_output_format_is_rejected() {
+ let sandbox = RadrootsCliSandbox::new();
+ let output = sandbox
+ .command()
+ .env("RADROOTS_CLI_OUTPUT_FORMAT", "human")
+ .args(["workspace", "get"])
+ .output()
+ .expect("run env human format rejection");
+
+ assert_eq!(output.status.code(), Some(2));
+ assert!(output.stdout.is_empty());
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
+
+ assert!(stderr.starts_with("✕ Invalid input\n"));
+ assert!(stderr.contains("RADROOTS_CLI_OUTPUT_FORMAT must be `terminal`, `json`, or `ndjson`"));
+}
+
+#[test]
+fn terminal_health_status_surfaces_publish_reason_and_actions() {
let sandbox = RadrootsCliSandbox::new();
let output = sandbox
.command()
.args(["--relay", "ws://127.0.0.1:19007", "health", "status", "get"])
.output()
- .expect("run human health status");
+ .expect("run terminal health status");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
- assert!(stdout.starts_with("health.status.get: needs_attention\n"));
- assert!(stdout.contains("publish_state: unconfigured"));
- assert!(stdout.contains("reason: direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes"));
- assert!(stdout.contains("- radroots account create"));
+ assert!(stdout.starts_with("! Health status get needs attention\n"));
+ assert!(stdout.contains("Publish unconfigured"));
+ assert!(stdout.contains("Reason direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes"));
+ assert!(stdout.contains("Next\n radroots account create"));
assert!(serde_json::from_str::<Value>(&stdout).is_err());
}
#[test]
-fn human_market_refresh_missing_store_shows_action() {
+fn terminal_market_refresh_missing_store_shows_action() {
let sandbox = RadrootsCliSandbox::new();
let output = sandbox
.command()
.args(["market", "refresh"])
.output()
- .expect("run human market refresh");
+ .expect("run terminal market refresh");
assert!(output.status.success());
let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
- assert!(stdout.starts_with("market.refresh: unconfigured\n"));
- assert!(stdout.contains("reason: local replica database is not initialized"));
- assert!(stdout.contains("- radroots store init"));
+ assert!(stdout.starts_with("! Market refresh unconfigured\n"));
+ assert!(stdout.contains("Reason local replica database is not initialized"));
+ assert!(stdout.contains("Next\n radroots store init"));
assert!(serde_json::from_str::<Value>(&stdout).is_err());
}
#[test]
-fn human_failure_output_preserves_error_code_and_message() {
+fn terminal_failure_output_routes_to_stderr_and_preserves_message() {
let output = radroots()
- .args(["--format", "human", "trade", "submit"])
+ .args(["--format", "terminal", "trade", "submit"])
.output()
.expect("run trade submit");
assert_eq!(output.status.code(), Some(6));
- let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(output.stdout.is_empty());
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
- assert!(stdout.starts_with("trade.submit: error\n"));
- assert!(stdout.contains("request_id: req_trade_submit_"));
- assert!(stdout.contains("error: approval_required"));
- assert!(stdout.contains("message: missing required `approval_token` input"));
- assert!(serde_json::from_str::<Value>(&stdout).is_err());
+ assert!(stderr.starts_with("✕ Approval required\n"));
+ assert!(stderr.contains("Reason missing required `approval_token` input"));
+ assert!(serde_json::from_str::<Value>(&stderr).is_err());
}
#[test]
-fn human_failure_output_renders_structured_error_detail() {
+fn terminal_failure_output_renders_structured_error_detail() {
let output = radroots()
.args([
"--format",
- "human",
+ "terminal",
"trade",
"event",
"watch",
@@ -2869,15 +2936,14 @@ fn human_failure_output_renders_structured_error_detail() {
.expect("run order event watch");
assert_eq!(output.status.code(), Some(3));
- let stdout = String::from_utf8(output.stdout).expect("utf8 stdout");
+ assert!(output.stdout.is_empty());
+ let stderr = String::from_utf8(output.stderr).expect("utf8 stderr");
- assert!(stdout.starts_with("trade.event.watch: error\n"));
- assert!(stdout.contains("request_id: req_trade_event_watch_"));
- assert!(stdout.contains("error: not_implemented"));
- assert!(stdout.contains("state: not_implemented"));
- assert!(stdout.contains("reason: relay-backed trade event watch is not implemented"));
- assert!(stdout.contains("- radroots trade status get ord_missing"));
- assert!(serde_json::from_str::<Value>(&stdout).is_err());
+ assert!(stderr.starts_with("✕ Command failed\n"));
+ assert!(stderr.contains("Reason relay-backed trade event watch is not implemented"));
+ assert!(stderr.contains("State not implemented"));
+ assert!(stderr.contains("Next\n radroots trade status get ord_missing"));
+ assert!(serde_json::from_str::<Value>(&stderr).is_err());
}
#[test]