cli

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

commit bccb5d0432da311b68a4a003184e1a93330ff488
parent 6ba026c0f188b650567b5388611b279133d62cbe
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 06:17:25 +0000

cli: split runtime failure codes

- map runtime failures to precise public codes
- attach class detail for account signer provider and network errors
- remove the collapsed availability label from output paths
- update signer runtime coverage for signer failures

Diffstat:
Msrc/operation_adapter.rs | 314+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Msrc/operation_listing.rs | 10+++++-----
Msrc/operation_order.rs | 10+++++-----
Msrc/output_contract.rs | 4++--
Mtests/signer_runtime_modes.rs | 5+++--
5 files changed, 315 insertions(+), 28 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; use serde::Serialize; -use serde_json::{Map, Value}; +use serde_json::{Map, Value, json}; use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation}; use crate::output_contract::{ @@ -312,13 +312,43 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, - #[error("operation runtime error: {0}")] - Runtime(String), - #[error("operation `{operation_id}` is unavailable or unconfigured: {message}")] - UnavailableOrUnconfigured { + #[error("account unresolved for `{operation_id}`: {message}")] + AccountUnresolved { + operation_id: String, + message: String, + }, + #[error("account is watch-only for `{operation_id}`: {message}")] + AccountWatchOnly { + operation_id: String, + message: String, + }, + #[error("signer unconfigured for `{operation_id}`: {message}")] + SignerUnconfigured { + operation_id: String, + message: String, + }, + #[error("signer unavailable for `{operation_id}`: {message}")] + SignerUnavailable { + operation_id: String, + message: String, + }, + #[error("provider unconfigured for `{operation_id}`: {message}")] + ProviderUnconfigured { operation_id: String, message: String, }, + #[error("provider unavailable for `{operation_id}`: {message}")] + ProviderUnavailable { + operation_id: String, + message: String, + }, + #[error("operation `{operation_id}` is unavailable: {message}")] + OperationUnavailable { + operation_id: String, + message: String, + }, + #[error("operation runtime error: {0}")] + Runtime(String), } impl OperationAdapterError { @@ -329,6 +359,22 @@ impl OperationAdapterError { } } + pub fn unconfigured(operation_id: &str, message: String) -> Self { + classify_runtime_failure( + operation_id, + message, + RuntimeFailureAvailability::Unconfigured, + ) + } + + pub fn unavailable(operation_id: &str, message: String) -> Self { + classify_runtime_failure( + operation_id, + message, + RuntimeFailureAvailability::Unavailable, + ) + } + pub fn to_output_error(&self) -> OutputError { match self { Self::ApprovalRequired { message, .. } => OutputError::new( @@ -339,16 +385,96 @@ impl OperationAdapterError { Self::InvalidInput { message, .. } => { OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) } - Self::OfflineForbidden { message, .. } => OutputError::new( + Self::OfflineForbidden { + operation_id, + message, + } => runtime_output_error( "offline_forbidden", - message.clone(), + operation_id, + "network", + message, CliExitCode::SyncOrNetworkFailure, ), - Self::NetworkUnavailable { message, .. } => OutputError::new( + Self::NetworkUnavailable { + operation_id, + message, + } => runtime_output_error( "network_unavailable", - message.clone(), + operation_id, + "network", + message, CliExitCode::SyncOrNetworkFailure, ), + Self::AccountUnresolved { + operation_id, + message, + } => runtime_output_error( + "account_unresolved", + operation_id, + "account", + message, + CliExitCode::AuthorizationFailed, + ), + Self::AccountWatchOnly { + operation_id, + message, + } => runtime_output_error( + "account_watch_only", + operation_id, + "account", + message, + CliExitCode::SignerUnavailable, + ), + Self::SignerUnconfigured { + operation_id, + message, + } => runtime_output_error( + "signer_unconfigured", + operation_id, + "signer", + message, + CliExitCode::SignerUnavailable, + ), + Self::SignerUnavailable { + operation_id, + message, + } => runtime_output_error( + "signer_unavailable", + operation_id, + "signer", + message, + CliExitCode::SignerUnavailable, + ), + Self::ProviderUnconfigured { + operation_id, + message, + } => runtime_output_error( + "provider_unconfigured", + operation_id, + "provider", + message, + CliExitCode::RuntimeUnavailable, + ), + Self::ProviderUnavailable { + operation_id, + message, + } => runtime_output_error( + "provider_unavailable", + operation_id, + "provider", + message, + CliExitCode::RuntimeUnavailable, + ), + Self::OperationUnavailable { + operation_id, + message, + } => runtime_output_error( + "operation_unavailable", + operation_id, + "operation", + message, + CliExitCode::RuntimeUnavailable, + ), Self::UnknownOperation(operation_id) => OutputError::new( "unknown_operation", format!("unknown operation `{operation_id}`"), @@ -367,15 +493,114 @@ impl OperationAdapterError { Self::Runtime(message) => { OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError) } - Self::UnavailableOrUnconfigured { message, .. } => OutputError::new( - "unavailable_or_unconfigured", - message.clone(), - CliExitCode::UnavailableOrUnconfigured, - ), } } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RuntimeFailureAvailability { + Unconfigured, + Unavailable, +} + +fn classify_runtime_failure( + operation_id: &str, + message: String, + availability: RuntimeFailureAvailability, +) -> OperationAdapterError { + let lowered = message.to_ascii_lowercase(); + if contains_any(&lowered, &["watch_only", "watch-only", "watch only"]) { + return OperationAdapterError::AccountWatchOnly { + operation_id: operation_id.to_owned(), + message, + }; + } + if contains_any( + &lowered, + &[ + "no account", + "account selection", + "unresolved account", + "selected account", + ], + ) { + return OperationAdapterError::AccountUnresolved { + operation_id: operation_id.to_owned(), + message, + }; + } + if contains_any( + &lowered, + &[ + "signer", + "sign_event", + "remote_nip46", + "nip46", + "secret-backed", + "secret backed", + ], + ) { + return match availability { + RuntimeFailureAvailability::Unconfigured => OperationAdapterError::SignerUnconfigured { + operation_id: operation_id.to_owned(), + message, + }, + RuntimeFailureAvailability::Unavailable => OperationAdapterError::SignerUnavailable { + operation_id: operation_id.to_owned(), + message, + }, + }; + } + if contains_any( + &lowered, + &[ + "provider", + "write-plane", + "write plane", + "radrootsd", + "bridge", + "rpc", + "daemon", + ], + ) { + return match availability { + RuntimeFailureAvailability::Unconfigured => { + OperationAdapterError::ProviderUnconfigured { + operation_id: operation_id.to_owned(), + message, + } + } + RuntimeFailureAvailability::Unavailable => OperationAdapterError::ProviderUnavailable { + operation_id: operation_id.to_owned(), + message, + }, + }; + } + OperationAdapterError::OperationUnavailable { + operation_id: operation_id.to_owned(), + message, + } +} + +fn contains_any(value: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| value.contains(needle)) +} + +fn runtime_output_error( + code: &str, + operation_id: &str, + class: &str, + message: &str, + exit_code: CliExitCode, +) -> OutputError { + let mut error = OutputError::new(code, message.to_owned(), exit_code); + error.detail = Some(json!({ + "operation_id": operation_id, + "class": class, + })); + error +} + macro_rules! mvp_operation_contracts { ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { #[derive(Debug, Clone, PartialEq)] @@ -873,4 +1098,65 @@ mod tests { assert_eq!(output_error.exit_code, 6); assert!(output_error.message.contains("approval_token")); } + + #[test] + fn runtime_failures_map_to_specific_machine_codes() { + let cases = [ + ( + OperationAdapterError::unconfigured( + "listing.publish", + "no selected account for seller write".to_owned(), + ), + "account_unresolved", + "account", + 5, + ), + ( + OperationAdapterError::unconfigured( + "listing.publish", + "watch_only account cannot sign".to_owned(), + ), + "account_watch_only", + "account", + 7, + ), + ( + OperationAdapterError::unconfigured( + "listing.publish", + "signer.remote_nip46 binding is missing".to_owned(), + ), + "signer_unconfigured", + "signer", + 7, + ), + ( + OperationAdapterError::unavailable( + "listing.publish", + "radrootsd bridge is unavailable".to_owned(), + ), + "provider_unavailable", + "provider", + 3, + ), + ( + OperationAdapterError::unconfigured( + "basket.quote.create", + "quote engine not ready".to_owned(), + ), + "operation_unavailable", + "operation", + 3, + ), + ]; + + for (error, code, class, exit_code) in cases { + let output = error.to_output_error(); + assert_eq!(output.code, code); + assert_eq!(output.exit_code, exit_code); + assert_eq!( + output.detail.expect("detail")["class"], + serde_json::Value::String(class.to_owned()) + ); + } + } } diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -247,11 +247,11 @@ fn disposition_error( ) -> OperationAdapterError { match disposition { CommandDisposition::Success => OperationAdapterError::Runtime(message), - CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => { - OperationAdapterError::UnavailableOrUnconfigured { - operation_id: operation_id.to_owned(), - message, - } + CommandDisposition::Unconfigured => { + OperationAdapterError::unconfigured(operation_id, message) + } + CommandDisposition::ExternalUnavailable => { + OperationAdapterError::unavailable(operation_id, message) } CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { operation_id: operation_id.to_owned(), diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -154,11 +154,11 @@ fn disposition_error( ) -> OperationAdapterError { match disposition { CommandDisposition::Success => OperationAdapterError::Runtime(message), - CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => { - OperationAdapterError::UnavailableOrUnconfigured { - operation_id: operation_id.to_owned(), - message, - } + CommandDisposition::Unconfigured => { + OperationAdapterError::unconfigured(operation_id, message) + } + CommandDisposition::ExternalUnavailable => { + OperationAdapterError::unavailable(operation_id, message) } CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { operation_id: operation_id.to_owned(), diff --git a/src/output_contract.rs b/src/output_contract.rs @@ -128,7 +128,7 @@ pub enum CliExitCode { Success, InternalError, InvalidInput, - UnavailableOrUnconfigured, + RuntimeUnavailable, NotFound, AuthorizationFailed, ApprovalRequiredOrDenied, @@ -145,7 +145,7 @@ impl CliExitCode { Self::Success => 0, Self::InternalError => 1, Self::InvalidInput => 2, - Self::UnavailableOrUnconfigured => 3, + Self::RuntimeUnavailable => 3, Self::NotFound => 4, Self::AuthorizationFailed => 5, Self::ApprovalRequiredOrDenied => 6, diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -289,8 +289,9 @@ fn myc_listing_publish_does_not_fallback_to_local_account() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], serde_json::Value::Null); - assert_eq!(value["errors"][0]["code"], "unavailable_or_unconfigured"); - assert_eq!(value["errors"][0]["exit_code"], 3); + assert_eq!(value["errors"][0]["code"], "signer_unconfigured"); + assert_eq!(value["errors"][0]["exit_code"], 7); + assert_eq!(value["errors"][0]["detail"]["class"], "signer"); assert_contains(&value["errors"][0]["message"], "signer.remote_nip46"); }