cli

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

error.rs (31623B)


      1 use std::io::ErrorKind;
      2 
      3 use radroots_sdk::{RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkRecoveryAction};
      4 use serde_json::{Map, Value, json};
      5 
      6 use crate::out::envelope::{CliExitCode, OutputError};
      7 use crate::runtime::RuntimeError;
      8 use crate::runtime::account::AccountRuntimeFailure;
      9 use crate::runtime::sdk::CliSdkAdapterError;
     10 use crate::view::runtime::CommandDisposition;
     11 
     12 #[derive(Debug, thiserror::Error, PartialEq, Eq)]
     13 pub enum OperationAdapterError {
     14     #[error("unknown operation `{0}`")]
     15     UnknownOperation(String),
     16     #[error(
     17         "operation `{operation_id}` registry request `{registry_request}` does not match adapter request `{adapter_request}`"
     18     )]
     19     RequestTypeMismatch {
     20         operation_id: String,
     21         registry_request: String,
     22         adapter_request: String,
     23     },
     24     #[error(
     25         "operation `{operation_id}` registry result `{registry_result}` does not match adapter result `{adapter_result}`"
     26     )]
     27     ResultTypeMismatch {
     28         operation_id: String,
     29         registry_result: String,
     30         adapter_result: String,
     31     },
     32     #[error("failed to serialize operation result: {0}")]
     33     Serialization(String),
     34     #[error("invalid operation input for `{operation_id}`: {message}")]
     35     InvalidInput {
     36         operation_id: String,
     37         message: String,
     38     },
     39     #[error("resource not found for `{operation_id}`: {message}")]
     40     NotFound {
     41         operation_id: String,
     42         message: String,
     43     },
     44     #[error("validation failed for `{operation_id}`: {message}")]
     45     ValidationFailed {
     46         operation_id: String,
     47         message: String,
     48     },
     49     #[error("approval required for `{operation_id}`: {message}")]
     50     ApprovalRequired {
     51         operation_id: String,
     52         message: String,
     53     },
     54     #[error("operation `{operation_id}` is forbidden while offline: {message}")]
     55     OfflineForbidden {
     56         operation_id: String,
     57         message: String,
     58     },
     59     #[error("operation `{operation_id}` cannot run online: {message}")]
     60     NetworkUnavailable {
     61         operation_id: String,
     62         message: String,
     63     },
     64     #[error("account unresolved for `{operation_id}`: {message}")]
     65     AccountUnresolved {
     66         operation_id: String,
     67         message: String,
     68     },
     69     #[error("account is watch-only for `{operation_id}`: {message}")]
     70     AccountWatchOnly {
     71         operation_id: String,
     72         message: String,
     73     },
     74     #[error("account mismatch for `{operation_id}`: {message}")]
     75     AccountMismatch {
     76         operation_id: String,
     77         message: String,
     78     },
     79     #[error("signer unconfigured for `{operation_id}`: {message}")]
     80     SignerUnconfigured {
     81         operation_id: String,
     82         message: String,
     83     },
     84     #[error("signer unavailable for `{operation_id}`: {message}")]
     85     SignerUnavailable {
     86         operation_id: String,
     87         message: String,
     88     },
     89     #[error("signer mode deferred for `{operation_id}`: {message}")]
     90     SignerModeDeferred {
     91         operation_id: String,
     92         message: String,
     93     },
     94     #[error("provider unconfigured for `{operation_id}`: {message}")]
     95     ProviderUnconfigured {
     96         operation_id: String,
     97         message: String,
     98     },
     99     #[error("provider unavailable for `{operation_id}`: {message}")]
    100     ProviderUnavailable {
    101         operation_id: String,
    102         message: String,
    103     },
    104     #[error("operation `{operation_id}` is unavailable: {message}")]
    105     OperationUnavailable {
    106         operation_id: String,
    107         message: String,
    108     },
    109     #[error("operation `{operation_id}` is not implemented: {message}")]
    110     NotImplemented {
    111         operation_id: String,
    112         message: String,
    113     },
    114     #[error("operation `{operation_id}` failed: {message}")]
    115     DetailedFailure {
    116         operation_id: String,
    117         code: String,
    118         class: String,
    119         message: String,
    120         exit_code: CliExitCode,
    121         detail_json: String,
    122     },
    123     #[error("operation runtime error: {0}")]
    124     Runtime(String),
    125 }
    126 
    127 impl OperationAdapterError {
    128     pub fn approval_required(operation_id: &str) -> Self {
    129         Self::ApprovalRequired {
    130             operation_id: operation_id.to_owned(),
    131             message: "missing required `approval_token` input".to_owned(),
    132         }
    133     }
    134 
    135     pub fn from_command_disposition(
    136         operation_id: &str,
    137         disposition: CommandDisposition,
    138         message: String,
    139     ) -> Self {
    140         match disposition {
    141             CommandDisposition::Success => Self::Runtime(message),
    142             CommandDisposition::NotFound => Self::NotFound {
    143                 operation_id: operation_id.to_owned(),
    144                 message,
    145             },
    146             CommandDisposition::ValidationFailed => Self::ValidationFailed {
    147                 operation_id: operation_id.to_owned(),
    148                 message,
    149             },
    150             CommandDisposition::Unconfigured => Self::unconfigured(operation_id, message),
    151             CommandDisposition::ExternalUnavailable => Self::unavailable(operation_id, message),
    152             CommandDisposition::Unsupported => Self::InvalidInput {
    153                 operation_id: operation_id.to_owned(),
    154                 message,
    155             },
    156             CommandDisposition::InternalError => Self::Runtime(message),
    157         }
    158     }
    159 
    160     pub fn unconfigured(operation_id: &str, message: String) -> Self {
    161         classify_runtime_failure(
    162             operation_id,
    163             message,
    164             RuntimeFailureAvailability::Unconfigured,
    165         )
    166     }
    167 
    168     pub fn operation_unavailable_with_detail(
    169         operation_id: &str,
    170         message: String,
    171         detail: Value,
    172     ) -> Self {
    173         Self::DetailedFailure {
    174             operation_id: operation_id.to_owned(),
    175             code: "operation_unavailable".to_owned(),
    176             class: "operation".to_owned(),
    177             message,
    178             exit_code: CliExitCode::RuntimeUnavailable,
    179             detail_json: detail.to_string(),
    180         }
    181     }
    182 
    183     pub fn not_found_with_detail(operation_id: &str, message: String, detail: Value) -> Self {
    184         Self::DetailedFailure {
    185             operation_id: operation_id.to_owned(),
    186             code: "not_found".to_owned(),
    187             class: "resource".to_owned(),
    188             message,
    189             exit_code: CliExitCode::NotFound,
    190             detail_json: detail.to_string(),
    191         }
    192     }
    193 
    194     pub fn not_implemented(operation_id: &str, message: String) -> Self {
    195         Self::NotImplemented {
    196             operation_id: operation_id.to_owned(),
    197             message,
    198         }
    199     }
    200 
    201     pub fn not_implemented_with_detail(operation_id: &str, message: String, detail: Value) -> Self {
    202         Self::DetailedFailure {
    203             operation_id: operation_id.to_owned(),
    204             code: "not_implemented".to_owned(),
    205             class: "operation".to_owned(),
    206             message,
    207             exit_code: CliExitCode::RuntimeUnavailable,
    208             detail_json: detail.to_string(),
    209         }
    210     }
    211 
    212     pub fn network_unavailable_with_detail(
    213         operation_id: &str,
    214         message: String,
    215         detail: Value,
    216     ) -> Self {
    217         Self::DetailedFailure {
    218             operation_id: operation_id.to_owned(),
    219             code: "network_unavailable".to_owned(),
    220             class: "network".to_owned(),
    221             message,
    222             exit_code: CliExitCode::SyncOrNetworkFailure,
    223             detail_json: detail.to_string(),
    224         }
    225     }
    226 
    227     pub fn validation_failed_with_detail(
    228         operation_id: &str,
    229         message: String,
    230         detail: Value,
    231     ) -> Self {
    232         Self::DetailedFailure {
    233             operation_id: operation_id.to_owned(),
    234             code: "validation_failed".to_owned(),
    235             class: "validation".to_owned(),
    236             message,
    237             exit_code: CliExitCode::ValidationFailed,
    238             detail_json: detail.to_string(),
    239         }
    240     }
    241 
    242     pub fn unavailable(operation_id: &str, message: String) -> Self {
    243         classify_runtime_failure(
    244             operation_id,
    245             message,
    246             RuntimeFailureAvailability::Unavailable,
    247         )
    248     }
    249 
    250     pub fn runtime_failure(operation_id: &str, error: RuntimeError) -> Self {
    251         let message = error.to_string();
    252         let lowered = message.to_ascii_lowercase();
    253         match &error {
    254             RuntimeError::Io(io_error) if io_error.kind() == ErrorKind::NotFound => {
    255                 Self::NotFound {
    256                     operation_id: operation_id.to_owned(),
    257                     message,
    258                 }
    259             }
    260             RuntimeError::Config(_) if looks_like_not_found(&lowered) => Self::NotFound {
    261                 operation_id: operation_id.to_owned(),
    262                 message,
    263             },
    264             RuntimeError::Account(failure) => account_runtime_failure(operation_id, failure),
    265             RuntimeError::Config(_)
    266                 if contains_any(
    267                     &lowered,
    268                     &[
    269                         "no local account",
    270                         "account selector",
    271                         "account selection",
    272                         "account mismatch",
    273                         "did not match any local account",
    274                         "unresolved account",
    275                     ],
    276                 ) =>
    277             {
    278                 classify_runtime_failure(
    279                     operation_id,
    280                     message,
    281                     RuntimeFailureAvailability::Unconfigured,
    282                 )
    283             }
    284             RuntimeError::Config(_) if looks_like_signer_failure(&lowered) => {
    285                 Self::SignerUnconfigured {
    286                     operation_id: operation_id.to_owned(),
    287                     message,
    288                 }
    289             }
    290             RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => {
    291                 Self::ValidationFailed {
    292                     operation_id: operation_id.to_owned(),
    293                     message,
    294                 }
    295             }
    296             RuntimeError::Network(_) if looks_like_auth_failure(&lowered) => {
    297                 auth_runtime_failure(operation_id, message, &lowered)
    298             }
    299             RuntimeError::Network(_) if looks_like_signer_failure(&lowered) => {
    300                 Self::SignerUnavailable {
    301                     operation_id: operation_id.to_owned(),
    302                     message,
    303                 }
    304             }
    305             RuntimeError::Network(_) if looks_like_provider_failure(&lowered) => {
    306                 Self::ProviderUnavailable {
    307                     operation_id: operation_id.to_owned(),
    308                     message,
    309                 }
    310             }
    311             RuntimeError::Network(_) if looks_like_operation_failure(&lowered) => {
    312                 Self::OperationUnavailable {
    313                     operation_id: operation_id.to_owned(),
    314                     message,
    315                 }
    316             }
    317             RuntimeError::Network(_) => Self::NetworkUnavailable {
    318                 operation_id: operation_id.to_owned(),
    319                 message,
    320             },
    321             RuntimeError::Accounts(_) => classify_runtime_failure(
    322                 operation_id,
    323                 message,
    324                 RuntimeFailureAvailability::Unavailable,
    325             ),
    326             _ => Self::Runtime(message),
    327         }
    328     }
    329 
    330     pub fn sdk_adapter_failure(operation_id: &str, error: CliSdkAdapterError) -> Self {
    331         match error {
    332             CliSdkAdapterError::Runtime(error) => Self::runtime_failure(operation_id, error),
    333             CliSdkAdapterError::Sdk(error) => Self::sdk_failure(operation_id, error),
    334         }
    335     }
    336 
    337     pub fn sdk_failure(operation_id: &str, error: RadrootsSdkError) -> Self {
    338         let code = error.code().to_owned();
    339         let class = sdk_error_class_name(error.class()).to_owned();
    340         let message = error.to_string();
    341         let exit_code = sdk_error_exit_code(error.class());
    342         let mut detail = error.detail_json();
    343         let actions = sdk_recovery_next_actions(operation_id, &error.recovery_actions());
    344         if !actions.is_empty()
    345             && let Some(detail) = detail.as_object_mut()
    346         {
    347             detail.insert(
    348                 "actions".to_owned(),
    349                 Value::Array(actions.into_iter().map(Value::String).collect()),
    350             );
    351         }
    352         Self::DetailedFailure {
    353             operation_id: operation_id.to_owned(),
    354             code,
    355             class,
    356             message,
    357             exit_code,
    358             detail_json: detail.to_string(),
    359         }
    360     }
    361 
    362     pub fn to_output_error(&self) -> OutputError {
    363         match self {
    364             Self::ApprovalRequired { message, .. } => OutputError::new(
    365                 "approval_required",
    366                 message.clone(),
    367                 CliExitCode::ApprovalRequiredOrDenied,
    368             ),
    369             Self::InvalidInput { message, .. } => {
    370                 OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput)
    371             }
    372             Self::NotFound {
    373                 operation_id,
    374                 message,
    375             } => runtime_output_error(
    376                 "not_found",
    377                 operation_id,
    378                 "resource",
    379                 message,
    380                 CliExitCode::NotFound,
    381             ),
    382             Self::ValidationFailed {
    383                 operation_id,
    384                 message,
    385             } => runtime_output_error(
    386                 "validation_failed",
    387                 operation_id,
    388                 "validation",
    389                 message,
    390                 CliExitCode::ValidationFailed,
    391             ),
    392             Self::OfflineForbidden {
    393                 operation_id,
    394                 message,
    395             } => runtime_output_error(
    396                 "offline_forbidden",
    397                 operation_id,
    398                 "network",
    399                 message,
    400                 CliExitCode::SyncOrNetworkFailure,
    401             ),
    402             Self::NetworkUnavailable {
    403                 operation_id,
    404                 message,
    405             } => runtime_output_error(
    406                 "network_unavailable",
    407                 operation_id,
    408                 "network",
    409                 message,
    410                 CliExitCode::SyncOrNetworkFailure,
    411             ),
    412             Self::AccountUnresolved {
    413                 operation_id,
    414                 message,
    415             } => runtime_output_error(
    416                 "account_unresolved",
    417                 operation_id,
    418                 "account",
    419                 message,
    420                 CliExitCode::AuthorizationFailed,
    421             ),
    422             Self::AccountWatchOnly {
    423                 operation_id,
    424                 message,
    425             } => runtime_output_error(
    426                 "account_watch_only",
    427                 operation_id,
    428                 "account",
    429                 message,
    430                 CliExitCode::SignerUnavailable,
    431             ),
    432             Self::AccountMismatch {
    433                 operation_id,
    434                 message,
    435             } => runtime_output_error(
    436                 "account_mismatch",
    437                 operation_id,
    438                 "account",
    439                 message,
    440                 CliExitCode::AuthorizationFailed,
    441             ),
    442             Self::SignerUnconfigured {
    443                 operation_id,
    444                 message,
    445             } => runtime_output_error(
    446                 "signer_unconfigured",
    447                 operation_id,
    448                 "signer",
    449                 message,
    450                 CliExitCode::SignerUnavailable,
    451             ),
    452             Self::SignerUnavailable {
    453                 operation_id,
    454                 message,
    455             } => runtime_output_error(
    456                 "signer_unavailable",
    457                 operation_id,
    458                 "signer",
    459                 message,
    460                 CliExitCode::SignerUnavailable,
    461             ),
    462             Self::SignerModeDeferred {
    463                 operation_id,
    464                 message,
    465             } => runtime_output_error(
    466                 "signer_mode_deferred",
    467                 operation_id,
    468                 "signer",
    469                 message,
    470                 CliExitCode::SignerUnavailable,
    471             ),
    472             Self::ProviderUnconfigured {
    473                 operation_id,
    474                 message,
    475             } => runtime_output_error(
    476                 "provider_unconfigured",
    477                 operation_id,
    478                 "provider",
    479                 message,
    480                 CliExitCode::RuntimeUnavailable,
    481             ),
    482             Self::ProviderUnavailable {
    483                 operation_id,
    484                 message,
    485             } => runtime_output_error(
    486                 "provider_unavailable",
    487                 operation_id,
    488                 "provider",
    489                 message,
    490                 CliExitCode::RuntimeUnavailable,
    491             ),
    492             Self::OperationUnavailable {
    493                 operation_id,
    494                 message,
    495             } => runtime_output_error(
    496                 "operation_unavailable",
    497                 operation_id,
    498                 "operation",
    499                 message,
    500                 CliExitCode::RuntimeUnavailable,
    501             ),
    502             Self::NotImplemented {
    503                 operation_id,
    504                 message,
    505             } => runtime_output_error(
    506                 "not_implemented",
    507                 operation_id,
    508                 "operation",
    509                 message,
    510                 CliExitCode::RuntimeUnavailable,
    511             ),
    512             Self::DetailedFailure {
    513                 operation_id,
    514                 code,
    515                 class,
    516                 message,
    517                 exit_code,
    518                 detail_json,
    519             } => runtime_output_error_with_detail(
    520                 code.as_str(),
    521                 operation_id,
    522                 class,
    523                 message,
    524                 *exit_code,
    525                 detail_json,
    526             ),
    527             Self::UnknownOperation(operation_id) => OutputError::new(
    528                 "unknown_operation",
    529                 format!("unknown operation `{operation_id}`"),
    530                 CliExitCode::InvalidInput,
    531             ),
    532             Self::RequestTypeMismatch { .. } | Self::ResultTypeMismatch { .. } => OutputError::new(
    533                 "contract_mismatch",
    534                 self.to_string(),
    535                 CliExitCode::InternalError,
    536             ),
    537             Self::Serialization(message) => OutputError::new(
    538                 "serialization_failed",
    539                 message.clone(),
    540                 CliExitCode::InternalError,
    541             ),
    542             Self::Runtime(message) => {
    543                 OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError)
    544             }
    545         }
    546     }
    547 }
    548 
    549 fn sdk_error_exit_code(class: RadrootsSdkErrorClass) -> CliExitCode {
    550     match class {
    551         RadrootsSdkErrorClass::Authorization => CliExitCode::AuthorizationFailed,
    552         RadrootsSdkErrorClass::Clock
    553         | RadrootsSdkErrorClass::Configuration
    554         | RadrootsSdkErrorClass::Request => CliExitCode::InvalidInput,
    555         RadrootsSdkErrorClass::LocalMutation => CliExitCode::Conflict,
    556         RadrootsSdkErrorClass::Storage => CliExitCode::RuntimeUnavailable,
    557         RadrootsSdkErrorClass::Transport => CliExitCode::SyncOrNetworkFailure,
    558         RadrootsSdkErrorClass::Unsupported => CliExitCode::RuntimeUnavailable,
    559         _ => CliExitCode::InternalError,
    560     }
    561 }
    562 
    563 fn sdk_error_class_name(class: RadrootsSdkErrorClass) -> &'static str {
    564     match class {
    565         RadrootsSdkErrorClass::Authorization => "authorization",
    566         RadrootsSdkErrorClass::Clock => "clock",
    567         RadrootsSdkErrorClass::Configuration => "configuration",
    568         RadrootsSdkErrorClass::LocalMutation => "local_mutation",
    569         RadrootsSdkErrorClass::Request => "request",
    570         RadrootsSdkErrorClass::Storage => "storage",
    571         RadrootsSdkErrorClass::Transport => "transport",
    572         RadrootsSdkErrorClass::Unsupported => "unsupported",
    573         _ => "internal",
    574     }
    575 }
    576 
    577 fn sdk_recovery_next_actions(
    578     operation_id: &str,
    579     recovery_actions: &[RadrootsSdkRecoveryAction],
    580 ) -> Vec<String> {
    581     recovery_actions
    582         .iter()
    583         .filter_map(|action| match action {
    584             RadrootsSdkRecoveryAction::RetryOutboxEnqueue
    585             | RadrootsSdkRecoveryAction::RetryOperationWithSameIdempotencyKey
    586             | RadrootsSdkRecoveryAction::FixRequest => Some(operation_retry_action(operation_id)),
    587             RadrootsSdkRecoveryAction::InspectLocalStores => {
    588                 Some("radroots store status get".to_owned())
    589             }
    590             RadrootsSdkRecoveryAction::ConfigureRelayTargets => {
    591                 Some("radroots relay list".to_owned())
    592             }
    593             RadrootsSdkRecoveryAction::SelectAuthorizedActor => {
    594                 Some("radroots account list".to_owned())
    595             }
    596             RadrootsSdkRecoveryAction::RetryAfterTransportFailure => {
    597                 Some(operation_retry_action(operation_id))
    598             }
    599             RadrootsSdkRecoveryAction::EnableRequiredFeature => {
    600                 Some("radroots health status get".to_owned())
    601             }
    602             _ => None,
    603         })
    604         .fold(Vec::new(), |mut actions, action| {
    605             if !actions.contains(&action) {
    606                 actions.push(action);
    607             }
    608             actions
    609         })
    610 }
    611 
    612 fn operation_retry_action(operation_id: &str) -> String {
    613     format!("radroots {}", operation_id.replace('.', " "))
    614 }
    615 
    616 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    617 enum RuntimeFailureAvailability {
    618     Unconfigured,
    619     Unavailable,
    620 }
    621 
    622 fn account_runtime_failure(
    623     operation_id: &str,
    624     failure: &AccountRuntimeFailure,
    625 ) -> OperationAdapterError {
    626     let message = failure.message().to_owned();
    627     match failure {
    628         AccountRuntimeFailure::Unresolved(_) => account_failure_output(
    629             operation_id,
    630             "account_unresolved",
    631             message,
    632             CliExitCode::AuthorizationFailed,
    633             failure.detail_json(),
    634             || OperationAdapterError::AccountUnresolved {
    635                 operation_id: operation_id.to_owned(),
    636                 message: failure.message().to_owned(),
    637             },
    638         ),
    639         AccountRuntimeFailure::WatchOnly(_) => account_failure_output(
    640             operation_id,
    641             "account_watch_only",
    642             message,
    643             CliExitCode::SignerUnavailable,
    644             failure.detail_json(),
    645             || OperationAdapterError::AccountWatchOnly {
    646                 operation_id: operation_id.to_owned(),
    647                 message: failure.message().to_owned(),
    648             },
    649         ),
    650         AccountRuntimeFailure::Mismatch(_) => account_failure_output(
    651             operation_id,
    652             "account_mismatch",
    653             message,
    654             CliExitCode::AuthorizationFailed,
    655             failure.detail_json(),
    656             || OperationAdapterError::AccountMismatch {
    657                 operation_id: operation_id.to_owned(),
    658                 message: failure.message().to_owned(),
    659             },
    660         ),
    661     }
    662 }
    663 
    664 fn account_failure_output(
    665     operation_id: &str,
    666     code: &str,
    667     message: String,
    668     exit_code: CliExitCode,
    669     detail_json: Option<&str>,
    670     fallback: impl FnOnce() -> OperationAdapterError,
    671 ) -> OperationAdapterError {
    672     match detail_json {
    673         Some(detail_json) => OperationAdapterError::DetailedFailure {
    674             operation_id: operation_id.to_owned(),
    675             code: code.to_owned(),
    676             class: "account".to_owned(),
    677             message,
    678             exit_code,
    679             detail_json: detail_json.to_owned(),
    680         },
    681         None => fallback(),
    682     }
    683 }
    684 
    685 fn auth_runtime_failure(
    686     operation_id: &str,
    687     message: String,
    688     lowered: &str,
    689 ) -> OperationAdapterError {
    690     let unauthorized = contains_any(
    691         lowered,
    692         &[
    693             "unauthorized",
    694             "forbidden",
    695             "permission denied",
    696             "invalid token",
    697             "bearer token rejected",
    698             "http 401",
    699             "http 403",
    700             "status 401",
    701             "status 403",
    702         ],
    703     );
    704     OperationAdapterError::DetailedFailure {
    705         operation_id: operation_id.to_owned(),
    706         code: if unauthorized {
    707             "auth_unauthorized".to_owned()
    708         } else {
    709             "auth_unavailable".to_owned()
    710         },
    711         class: "auth".to_owned(),
    712         message,
    713         exit_code: CliExitCode::AuthorizationFailed,
    714         detail_json: Value::Null.to_string(),
    715     }
    716 }
    717 
    718 fn classify_runtime_failure(
    719     operation_id: &str,
    720     message: String,
    721     availability: RuntimeFailureAvailability,
    722 ) -> OperationAdapterError {
    723     let lowered = message.to_ascii_lowercase();
    724     if contains_any(&lowered, &["watch_only", "watch-only", "watch only"]) {
    725         return OperationAdapterError::AccountWatchOnly {
    726             operation_id: operation_id.to_owned(),
    727             message,
    728         };
    729     }
    730     if contains_any(&lowered, &["account mismatch"]) {
    731         return OperationAdapterError::AccountMismatch {
    732             operation_id: operation_id.to_owned(),
    733             message,
    734         };
    735     }
    736     if contains_any(
    737         &lowered,
    738         &[
    739             "no account",
    740             "no local account",
    741             "account selector",
    742             "account selection",
    743             "did not match any local account",
    744             "unresolved account",
    745             "selected account",
    746         ],
    747     ) {
    748         return OperationAdapterError::AccountUnresolved {
    749             operation_id: operation_id.to_owned(),
    750             message,
    751         };
    752     }
    753     if contains_any(
    754         &lowered,
    755         &[
    756             "signer",
    757             "sign_event",
    758             "remote_nip46",
    759             "nip46",
    760             "secret-backed",
    761             "secret backed",
    762         ],
    763     ) {
    764         return match availability {
    765             RuntimeFailureAvailability::Unconfigured => OperationAdapterError::SignerUnconfigured {
    766                 operation_id: operation_id.to_owned(),
    767                 message,
    768             },
    769             RuntimeFailureAvailability::Unavailable => OperationAdapterError::SignerUnavailable {
    770                 operation_id: operation_id.to_owned(),
    771                 message,
    772             },
    773         };
    774     }
    775     if contains_any(
    776         &lowered,
    777         &[
    778             "provider",
    779             "write-plane",
    780             "write plane",
    781             "radrootsd",
    782             "bridge",
    783             "rpc",
    784             "daemon",
    785         ],
    786     ) {
    787         return match availability {
    788             RuntimeFailureAvailability::Unconfigured => {
    789                 OperationAdapterError::ProviderUnconfigured {
    790                     operation_id: operation_id.to_owned(),
    791                     message,
    792                 }
    793             }
    794             RuntimeFailureAvailability::Unavailable => OperationAdapterError::ProviderUnavailable {
    795                 operation_id: operation_id.to_owned(),
    796                 message,
    797             },
    798         };
    799     }
    800     OperationAdapterError::OperationUnavailable {
    801         operation_id: operation_id.to_owned(),
    802         message,
    803     }
    804 }
    805 
    806 fn contains_any(value: &str, needles: &[&str]) -> bool {
    807     needles.iter().any(|needle| value.contains(needle))
    808 }
    809 
    810 fn looks_like_auth_failure(value: &str) -> bool {
    811     contains_any(
    812         value,
    813         &[
    814             "authentication",
    815             "bridge auth",
    816             "authorization",
    817             "authorize",
    818             "unauthorized",
    819             "forbidden",
    820             "bearer token",
    821             "invalid token",
    822             "permission denied",
    823             "status 401",
    824             "status 403",
    825             "http 401",
    826             "http 403",
    827         ],
    828     )
    829 }
    830 
    831 fn looks_like_signer_failure(value: &str) -> bool {
    832     contains_any(
    833         value,
    834         &[
    835             "signer",
    836             "sign_event",
    837             "sign event",
    838             "signer session",
    839             "nip46",
    840             "nip-46",
    841             "remote_nip46",
    842         ],
    843     )
    844 }
    845 
    846 fn looks_like_provider_failure(value: &str) -> bool {
    847     contains_any(
    848         value,
    849         &[
    850             "provider unavailable",
    851             "provider unconfigured",
    852             "provider runtime",
    853             "provider failed",
    854             "radrootsd unavailable",
    855             "daemon unavailable",
    856             "proxy provider",
    857         ],
    858     )
    859 }
    860 
    861 fn looks_like_operation_failure(value: &str) -> bool {
    862     contains_any(
    863         value,
    864         &[
    865             "method not found",
    866             "unknown method",
    867             "unsupported method",
    868             "unsupported operation",
    869             "operation unavailable",
    870             "operation disabled",
    871             "publish proxy disabled",
    872             "publish.event is disabled",
    873         ],
    874     )
    875 }
    876 
    877 fn looks_like_not_found(value: &str) -> bool {
    878     contains_any(
    879         value,
    880         &[
    881             "not found",
    882             "no such file or directory",
    883             "path not found",
    884             "missing file",
    885         ],
    886     )
    887 }
    888 
    889 fn looks_like_validation_failure(value: &str) -> bool {
    890     contains_any(
    891         value,
    892         &[
    893             "invalid",
    894             "parse ",
    895             "parse:",
    896             "must not",
    897             "must be",
    898             "validation",
    899             "failed to import account",
    900         ],
    901     )
    902 }
    903 
    904 fn runtime_output_error(
    905     code: &str,
    906     operation_id: &str,
    907     class: &str,
    908     message: &str,
    909     exit_code: CliExitCode,
    910 ) -> OutputError {
    911     let mut error = OutputError::new(code, message.to_owned(), exit_code);
    912     error.detail = Some(json!({
    913         "operation_id": operation_id,
    914         "class": class,
    915     }));
    916     error
    917 }
    918 
    919 fn runtime_output_error_with_detail(
    920     code: &str,
    921     operation_id: &str,
    922     class: &str,
    923     message: &str,
    924     exit_code: CliExitCode,
    925     detail_json: &str,
    926 ) -> OutputError {
    927     let mut error = OutputError::new(code, message.to_owned(), exit_code);
    928     let mut detail = serde_json::from_str::<Map<String, Value>>(detail_json).unwrap_or_default();
    929     detail.insert(
    930         "operation_id".to_owned(),
    931         Value::from(operation_id.to_owned()),
    932     );
    933     detail.insert("class".to_owned(), Value::from(class.to_owned()));
    934     error.detail = Some(Value::Object(detail));
    935     error
    936 }
    937 
    938 #[cfg(test)]
    939 mod tests {
    940     use super::*;
    941 
    942     #[test]
    943     fn sdk_storage_error_maps_to_typed_output_without_string_classification() {
    944         let error = OperationAdapterError::sdk_failure(
    945             "store.status.get",
    946             RadrootsSdkError::EventStore {
    947                 message: "database is locked".to_owned(),
    948             },
    949         );
    950 
    951         let output = error.to_output_error();
    952 
    953         assert_eq!(output.code, "event_store");
    954         assert_eq!(output.exit_code, CliExitCode::RuntimeUnavailable.code());
    955         let detail = output.detail.expect("detail");
    956         assert_eq!(detail["operation_id"], "store.status.get");
    957         assert_eq!(detail["class"], "storage");
    958         assert_eq!(detail["retryable"], true);
    959         assert_eq!(detail["detail"]["message"], "database is locked");
    960         assert_eq!(detail["actions"], json!(["radroots store status get"]));
    961     }
    962 
    963     #[test]
    964     fn sdk_request_error_maps_recovery_to_operation_retry_action() {
    965         let error = OperationAdapterError::sdk_failure(
    966             "listing.publish",
    967             RadrootsSdkError::InvalidRequest {
    968                 message: "idempotency key must not contain boundary whitespace".to_owned(),
    969             },
    970         );
    971 
    972         let output = error.to_output_error();
    973 
    974         assert_eq!(output.code, "invalid_request");
    975         assert_eq!(output.exit_code, CliExitCode::InvalidInput.code());
    976         let detail = output.detail.expect("detail");
    977         assert_eq!(detail["class"], "request");
    978         assert_eq!(detail["retryable"], false);
    979         assert_eq!(detail["actions"], json!(["radroots listing publish"]));
    980     }
    981 }