cli

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

commit ce55a8592556ad3fe4f522232482f3cba029cc36
parent d5aaed12621292f487d1465e3f03fbe537e8bcb5
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 08:50:15 +0000

cli: classify local runtime failures

- add stable not_found and validation_failed output errors
- route listing runtime failures through the typed classifier
- preserve account and signer classifications for listing writes
- cover missing and invalid listing draft failures in tests

Diffstat:
Msrc/operation_adapter.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_listing.rs | 40++++++++++++++++++++++++++--------------
Mtests/target_cli.rs | 27++++++++++++++++++++++++++-
3 files changed, 160 insertions(+), 15 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use std::fmt::Debug; +use std::io::ErrorKind; use serde::Serialize; use serde_json::{Map, Value, json}; @@ -10,6 +11,7 @@ use crate::output_contract::{ CliExitCode, EnvelopeActor, EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError, OutputWarning, }; +use crate::runtime::RuntimeError; use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -298,6 +300,16 @@ pub enum OperationAdapterError { operation_id: String, message: String, }, + #[error("resource not found for `{operation_id}`: {message}")] + NotFound { + operation_id: String, + message: String, + }, + #[error("validation failed for `{operation_id}`: {message}")] + ValidationFailed { + operation_id: String, + message: String, + }, #[error("approval required for `{operation_id}`: {message}")] ApprovalRequired { operation_id: String, @@ -386,6 +398,35 @@ impl OperationAdapterError { ) } + pub fn runtime_failure(operation_id: &str, error: RuntimeError) -> Self { + let message = error.to_string(); + let lowered = message.to_ascii_lowercase(); + match &error { + RuntimeError::Io(io_error) if io_error.kind() == ErrorKind::NotFound => { + Self::NotFound { + operation_id: operation_id.to_owned(), + message, + } + } + RuntimeError::Config(_) if looks_like_not_found(&lowered) => Self::NotFound { + operation_id: operation_id.to_owned(), + message, + }, + RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => { + Self::ValidationFailed { + operation_id: operation_id.to_owned(), + message, + } + } + RuntimeError::Accounts(_) => classify_runtime_failure( + operation_id, + message, + RuntimeFailureAvailability::Unavailable, + ), + _ => Self::Runtime(message), + } + } + pub fn to_output_error(&self) -> OutputError { match self { Self::ApprovalRequired { message, .. } => OutputError::new( @@ -396,6 +437,26 @@ impl OperationAdapterError { Self::InvalidInput { message, .. } => { OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) } + Self::NotFound { + operation_id, + message, + } => runtime_output_error( + "not_found", + operation_id, + "resource", + message, + CliExitCode::NotFound, + ), + Self::ValidationFailed { + operation_id, + message, + } => runtime_output_error( + "validation_failed", + operation_id, + "validation", + message, + CliExitCode::ValidationFailed, + ), Self::OfflineForbidden { operation_id, message, @@ -633,6 +694,32 @@ fn contains_any(value: &str, needles: &[&str]) -> bool { needles.iter().any(|needle| value.contains(needle)) } +fn looks_like_not_found(value: &str) -> bool { + contains_any( + value, + &[ + "not found", + "no such file or directory", + "path not found", + "missing file", + ], + ) +} + +fn looks_like_validation_failure(value: &str) -> bool { + contains_any( + value, + &[ + "invalid", + "parse ", + "parse:", + "must not", + "must be", + "validation", + ], + ) +} + fn runtime_output_error( code: &str, operation_id: &str, @@ -1056,6 +1143,8 @@ pub fn adapter_registry_linkage_is_valid() -> bool { #[cfg(test)] mod tests { + use std::io; + use clap::Parser; use serde_json::json; @@ -1066,6 +1155,7 @@ mod tests { adapter_registry_linkage_is_valid, }; use crate::operation_registry::OPERATION_REGISTRY; + use crate::runtime::RuntimeError; use crate::target_cli::TargetCliArgs; #[test] @@ -1250,6 +1340,24 @@ mod tests { "operation", 3, ), + ( + OperationAdapterError::runtime_failure( + "listing.publish", + RuntimeError::Io(io::Error::new(io::ErrorKind::NotFound, "missing draft")), + ), + "not_found", + "resource", + 4, + ), + ( + OperationAdapterError::runtime_failure( + "listing.validate", + RuntimeError::Config("invalid listing draft listing.toml".to_owned()), + ), + "validation_failed", + "validation", + 10, + ), ]; for (error, code, class, exit_code) in cases { diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -60,7 +60,10 @@ impl OperationService<ListingCreateRequest> for ListingOperationService<'_> { })); } - let view = map_runtime(crate::runtime::listing::scaffold(self.config, &args))?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::scaffold(self.config, &args), + )?; serialized_operation_result::<ListingCreateResult, _>(&view) } } @@ -75,7 +78,10 @@ impl OperationService<ListingGetRequest> for ListingOperationService<'_> { let args = RecordLookupArgs { key: required_string(&request, "key")?, }; - let view = map_runtime(crate::runtime::listing::get(self.config, &args))?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::get(self.config, &args), + )?; serialized_operation_result::<ListingGetResult, _>(&view) } } @@ -107,7 +113,10 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = map_runtime(crate::runtime::listing::update(&config, &args))?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::update(&config, &args), + )?; serialized_operation_result::<ListingUpdateResult, _>(&view) } } @@ -122,7 +131,10 @@ impl OperationService<ListingValidateRequest> for ListingOperationService<'_> { let args = ListingFileArgs { file: required_path(&request, "file")?, }; - let view = map_runtime(crate::runtime::listing::validate(self.config, &args))?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::validate(self.config, &args), + )?; serialized_operation_result::<ListingValidateResult, _>(&view) } } @@ -157,7 +169,10 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { } let args = mutation_args(&request)?; let config = mutation_config(self.config, &request); - let view = map_runtime(crate::runtime::listing::archive(&config, &args))?; + let view = map_runtime( + request.operation_id(), + crate::runtime::listing::archive(&config, &args), + )?; mutation_result::<ListingArchiveResult>(request.operation_id(), &view) } } @@ -262,8 +277,11 @@ where OperationResult::new(R::from_value(value)) } -fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +fn map_runtime<T>( + operation_id: &str, + result: Result<T, RuntimeError>, +) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) } fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAdapterError { @@ -276,13 +294,7 @@ fn publish_runtime_error(operation_id: &str, error: RuntimeError) -> OperationAd { return OperationAdapterError::unconfigured(operation_id, message); } - if matches!(&error, RuntimeError::Config(_)) { - return OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - }; - } - OperationAdapterError::Runtime(message) + OperationAdapterError::runtime_failure(operation_id, error) } fn required_string<P>( diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,5 +1,7 @@ mod support; +use std::fs; + use serde_json::Value; use support::{ @@ -345,7 +347,8 @@ fn listing_publish_dry_run_validates_missing_file() { assert!(!output.status.success()); assert_eq!(value["operation_id"], "listing.publish"); assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "runtime_error"); + assert_eq!(value["errors"][0]["code"], "not_found"); + assert_eq!(value["errors"][0]["exit_code"], 4); assert_no_removed_command_reference( &value, &["listing", "publish", "--dry-run", "missing-listing.toml"], @@ -353,6 +356,28 @@ fn listing_publish_dry_run_validates_missing_file() { } #[test] +fn listing_publish_invalid_draft_returns_validation_failure() { + let sandbox = RadrootsCliSandbox::new(); + let invalid = sandbox.root().join("invalid-listing.toml"); + fs::write(&invalid, "listing = [").expect("write invalid listing"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "listing", + "publish", + invalid.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "listing.publish"); + assert_eq!(value["result"], Value::Null); + assert_eq!(value["errors"][0]["code"], "validation_failed"); + assert_eq!(value["errors"][0]["exit_code"], 10); +} + +#[test] fn online_requires_relay_for_external_network_operations() { let output = radroots() .args(["--format", "json", "--online", "market", "refresh"])