cli

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

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:
M.env.example | 2+-
Msrc/cli/global.rs | 4++--
Msrc/cli/input.rs | 4++--
Msrc/cli/mod.rs | 8++++----
Msrc/main.rs | 463+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Msrc/ops/context.rs | 30++++++++++++++++++++++--------
Msrc/ops/exec/basket.rs | 2+-
Msrc/ops/exec/core.rs | 2+-
Msrc/ops/exec/farm.rs | 2+-
Msrc/ops/exec/listing.rs | 2+-
Msrc/ops/exec/market.rs | 2+-
Msrc/ops/exec/runtime.rs | 2+-
Msrc/ops/exec/trade.rs | 2+-
Msrc/ops/target.rs | 8+++++++-
Msrc/out/envelope.rs | 8++++----
Msrc/runtime/config.rs | 68+++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/runtime/farm.rs | 24++++++++++++------------
Msrc/runtime/provider.rs | 2+-
Msrc/runtime/sync.rs | 2+-
Mtests/target_cli.rs | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
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]