cli

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

mod.rs (20031B)


      1 #![allow(dead_code)]
      2 
      3 mod adapter;
      4 mod context;
      5 mod error;
      6 pub mod exec;
      7 mod request;
      8 mod result;
      9 mod target;
     10 
     11 pub use adapter::*;
     12 pub use context::*;
     13 pub use error::OperationAdapterError;
     14 pub use request::*;
     15 pub use result::*;
     16 pub use target::*;
     17 
     18 #[cfg(test)]
     19 mod tests {
     20     use std::io;
     21 
     22     use clap::Parser;
     23     use serde_json::{Value, json};
     24 
     25     use super::{
     26         OperationAdapter, OperationAdapterError, OperationContext, OperationInputMode,
     27         OperationNetworkMode, OperationOutputFormat, OperationRequest, OperationResult,
     28         OperationService, TargetOperationRequest, WorkspaceGetRequest, WorkspaceGetResult,
     29         adapter_registry_linkage_is_valid,
     30     };
     31     use crate::cli::TargetCliArgs;
     32     use crate::registry::OPERATION_REGISTRY;
     33     use crate::runtime::RuntimeError;
     34     use crate::runtime::account::AccountRuntimeFailure;
     35 
     36     #[test]
     37     fn adapter_binds_every_registry_entry() {
     38         assert!(adapter_registry_linkage_is_valid());
     39 
     40         for operation in OPERATION_REGISTRY {
     41             let parsed = TargetCliArgs::try_parse_from(operation.cli_path.split_whitespace())
     42                 .unwrap_or_else(|error| {
     43                     panic!("{} failed to parse: {error}", operation.cli_path);
     44                 });
     45             let request = TargetOperationRequest::from_target_args(&parsed)
     46                 .expect("operation request from target args");
     47 
     48             assert_eq!(request.operation_id(), operation.operation_id);
     49             assert_eq!(request.spec().mcp_tool, operation.mcp_tool);
     50             assert_eq!(request.request_type_name(), operation.rust_request);
     51             assert_eq!(
     52                 TargetOperationRequest::request_type_for_operation(operation.operation_id),
     53                 Some(operation.rust_request)
     54             );
     55         }
     56     }
     57 
     58     #[test]
     59     fn adapter_context_carries_target_global_scope() {
     60         let parsed = TargetCliArgs::try_parse_from([
     61             "radroots",
     62             "--format",
     63             "json",
     64             "--account-id",
     65             "acct_test",
     66             "--relay",
     67             "wss://relay.one",
     68             "--online",
     69             "--dry-run",
     70             "--idempotency-key",
     71             "idem_test",
     72             "--correlation-id",
     73             "corr_test",
     74             "--approval-token",
     75             "approval_test",
     76             "--no-input",
     77             "--quiet",
     78             "--verbose",
     79             "--trace",
     80             "--no-color",
     81             "workspace",
     82             "get",
     83         ])
     84         .expect("target args parse");
     85 
     86         let request = TargetOperationRequest::from_target_args(&parsed)
     87             .expect("operation request from target args");
     88         let context = request.context();
     89 
     90         assert_eq!(context.output_format, OperationOutputFormat::Json);
     91         assert_eq!(context.account_id.as_deref(), Some("acct_test"));
     92         assert_eq!(context.relays, vec!["wss://relay.one".to_owned()]);
     93         assert_eq!(context.network_mode, OperationNetworkMode::Online);
     94         assert!(context.dry_run);
     95         assert_eq!(context.idempotency_key.as_deref(), Some("idem_test"));
     96         assert_eq!(context.correlation_id.as_deref(), Some("corr_test"));
     97         assert_eq!(context.approval_token.as_deref(), Some("approval_test"));
     98         assert_eq!(context.input_mode, OperationInputMode::NoInput);
     99         assert!(context.quiet);
    100         assert!(context.verbose);
    101         assert!(context.trace);
    102         assert!(!context.color);
    103 
    104         let envelope_context = context.envelope_context("req_test");
    105         let actor = envelope_context.actor.expect("account actor");
    106         assert_eq!(actor.account_id, "acct_test");
    107         assert_eq!(actor.role, "account");
    108     }
    109 
    110     #[test]
    111     fn adapter_maps_account_attach_secret_input() {
    112         let parsed = TargetCliArgs::try_parse_from([
    113             "radroots",
    114             "account",
    115             "attach-secret",
    116             "acct_test",
    117             "identity.json",
    118             "--default",
    119         ])
    120         .expect("target args parse");
    121 
    122         let request = TargetOperationRequest::from_target_args(&parsed)
    123             .expect("operation request from target args");
    124         let TargetOperationRequest::AccountAttachSecret(request) = request else {
    125             panic!("expected account attach-secret request")
    126         };
    127 
    128         assert_eq!(request.operation_id(), "account.attach_secret");
    129         assert_eq!(
    130             request
    131                 .payload
    132                 .input
    133                 .get("selector")
    134                 .and_then(Value::as_str),
    135             Some("acct_test")
    136         );
    137         assert_eq!(
    138             request.payload.input.get("path").and_then(Value::as_str),
    139             Some("identity.json")
    140         );
    141         assert_eq!(
    142             request
    143                 .payload
    144                 .input
    145                 .get("default")
    146                 .and_then(Value::as_bool),
    147             Some(true)
    148         );
    149     }
    150 
    151     #[test]
    152     fn adapter_maps_farm_rebind_selector() {
    153         let parsed = TargetCliArgs::try_parse_from(["radroots", "farm", "rebind", "acct_test"])
    154             .expect("target args parse");
    155 
    156         let request = TargetOperationRequest::from_target_args(&parsed)
    157             .expect("operation request from target args");
    158         let TargetOperationRequest::FarmRebind(request) = request else {
    159             panic!("expected farm rebind request")
    160         };
    161 
    162         assert_eq!(request.operation_id(), "farm.rebind");
    163         assert_eq!(
    164             request
    165                 .payload
    166                 .input
    167                 .get("selector")
    168                 .and_then(Value::as_str),
    169             Some("acct_test")
    170         );
    171     }
    172 
    173     #[test]
    174     fn adapter_maps_listing_rebind_inputs() {
    175         let parsed = TargetCliArgs::try_parse_from([
    176             "radroots",
    177             "listing",
    178             "rebind",
    179             "listing.toml",
    180             "acct_test",
    181             "--farm-d-tag",
    182             "AAAAAAAAAAAAAAAAAAAAAw",
    183         ])
    184         .expect("target args parse");
    185 
    186         let request = TargetOperationRequest::from_target_args(&parsed)
    187             .expect("operation request from target args");
    188         let TargetOperationRequest::ListingRebind(request) = request else {
    189             panic!("expected listing rebind request")
    190         };
    191 
    192         assert_eq!(request.operation_id(), "listing.rebind");
    193         assert_eq!(
    194             request.payload.input.get("file").and_then(Value::as_str),
    195             Some("listing.toml")
    196         );
    197         assert_eq!(
    198             request
    199                 .payload
    200                 .input
    201                 .get("selector")
    202                 .and_then(Value::as_str),
    203             Some("acct_test")
    204         );
    205         assert_eq!(
    206             request
    207                 .payload
    208                 .input
    209                 .get("farm_d_tag")
    210                 .and_then(Value::as_str),
    211             Some("AAAAAAAAAAAAAAAAAAAAAw")
    212         );
    213     }
    214 
    215     #[test]
    216     fn adapter_maps_order_rebind_inputs() {
    217         let parsed =
    218             TargetCliArgs::try_parse_from(["radroots", "order", "rebind", "ord_test", "acct_test"])
    219                 .expect("target args parse");
    220 
    221         let request = TargetOperationRequest::from_target_args(&parsed)
    222             .expect("operation request from target args");
    223         let TargetOperationRequest::OrderRebind(request) = request else {
    224             panic!("expected order rebind request")
    225         };
    226 
    227         assert_eq!(request.operation_id(), "order.rebind");
    228         assert_eq!(
    229             request
    230                 .payload
    231                 .input
    232                 .get("order_id")
    233                 .and_then(Value::as_str),
    234             Some("ord_test")
    235         );
    236         assert_eq!(
    237             request
    238                 .payload
    239                 .input
    240                 .get("selector")
    241                 .and_then(Value::as_str),
    242             Some("acct_test")
    243         );
    244     }
    245 
    246     #[test]
    247     fn adapter_maps_order_lifecycle_inputs() {
    248         let revision = TargetCliArgs::try_parse_from([
    249             "radroots",
    250             "order",
    251             "revision",
    252             "propose",
    253             "ord_test",
    254             "--reason",
    255             "update count",
    256             "--bin-id",
    257             "bin-1",
    258             "--bin-count",
    259             "3",
    260             "--adjustment-id",
    261             "adj-weather",
    262             "--adjustment-effect",
    263             "increase",
    264             "--adjustment-amount",
    265             "1.25",
    266             "--adjustment-currency",
    267             "USD",
    268             "--adjustment-reason",
    269             "weather delay",
    270         ])
    271         .expect("target args parse");
    272         let request =
    273             TargetOperationRequest::from_target_args(&revision).expect("operation request");
    274         let TargetOperationRequest::OrderRevisionPropose(request) = request else {
    275             panic!("expected order revision propose request")
    276         };
    277         assert_eq!(request.operation_id(), "order.revision.propose");
    278         assert_eq!(
    279             request
    280                 .payload
    281                 .input
    282                 .get("order_id")
    283                 .and_then(Value::as_str),
    284             Some("ord_test")
    285         );
    286         assert_eq!(
    287             request.payload.input.get("reason").and_then(Value::as_str),
    288             Some("update count")
    289         );
    290         assert_eq!(
    291             request.payload.input.get("bin_id").and_then(Value::as_str),
    292             Some("bin-1")
    293         );
    294         assert_eq!(
    295             request
    296                 .payload
    297                 .input
    298                 .get("bin_count")
    299                 .and_then(Value::as_u64),
    300             Some(3)
    301         );
    302         assert_eq!(
    303             request
    304                 .payload
    305                 .input
    306                 .get("adjustment_id")
    307                 .and_then(Value::as_str),
    308             Some("adj-weather")
    309         );
    310         assert_eq!(
    311             request
    312                 .payload
    313                 .input
    314                 .get("adjustment_effect")
    315                 .and_then(Value::as_str),
    316             Some("increase")
    317         );
    318         assert_eq!(
    319             request
    320                 .payload
    321                 .input
    322                 .get("adjustment_amount")
    323                 .and_then(Value::as_str),
    324             Some("1.25")
    325         );
    326         assert_eq!(
    327             request
    328                 .payload
    329                 .input
    330                 .get("adjustment_currency")
    331                 .and_then(Value::as_str),
    332             Some("USD")
    333         );
    334         assert_eq!(
    335             request
    336                 .payload
    337                 .input
    338                 .get("adjustment_reason")
    339                 .and_then(Value::as_str),
    340             Some("weather delay")
    341         );
    342 
    343         let revision_accept = TargetCliArgs::try_parse_from([
    344             "radroots",
    345             "order",
    346             "revision",
    347             "accept",
    348             "ord_test",
    349             "--revision-id",
    350             "rev_test",
    351         ])
    352         .expect("target args parse");
    353         let request =
    354             TargetOperationRequest::from_target_args(&revision_accept).expect("operation request");
    355         let TargetOperationRequest::OrderRevisionAccept(request) = request else {
    356             panic!("expected order revision accept request")
    357         };
    358         assert_eq!(request.operation_id(), "order.revision.accept");
    359         assert_eq!(
    360             request
    361                 .payload
    362                 .input
    363                 .get("order_id")
    364                 .and_then(Value::as_str),
    365             Some("ord_test")
    366         );
    367         assert_eq!(
    368             request
    369                 .payload
    370                 .input
    371                 .get("revision_id")
    372                 .and_then(Value::as_str),
    373             Some("rev_test")
    374         );
    375 
    376         let revision_decline = TargetCliArgs::try_parse_from([
    377             "radroots",
    378             "order",
    379             "revision",
    380             "decline",
    381             "ord_test",
    382             "--revision-id",
    383             "rev_test",
    384             "--reason",
    385             "keep original order",
    386         ])
    387         .expect("target args parse");
    388         let request =
    389             TargetOperationRequest::from_target_args(&revision_decline).expect("operation request");
    390         let TargetOperationRequest::OrderRevisionDecline(request) = request else {
    391             panic!("expected order revision decline request")
    392         };
    393         assert_eq!(request.operation_id(), "order.revision.decline");
    394         assert_eq!(
    395             request
    396                 .payload
    397                 .input
    398                 .get("order_id")
    399                 .and_then(Value::as_str),
    400             Some("ord_test")
    401         );
    402         assert_eq!(
    403             request
    404                 .payload
    405                 .input
    406                 .get("revision_id")
    407                 .and_then(Value::as_str),
    408             Some("rev_test")
    409         );
    410         assert_eq!(
    411             request.payload.input.get("reason").and_then(Value::as_str),
    412             Some("keep original order")
    413         );
    414 
    415         let cancel = TargetCliArgs::try_parse_from([
    416             "radroots",
    417             "order",
    418             "cancel",
    419             "ord_test",
    420             "--reason",
    421             "changed plans",
    422         ])
    423         .expect("target args parse");
    424         let request = TargetOperationRequest::from_target_args(&cancel).expect("operation request");
    425         let TargetOperationRequest::OrderCancel(request) = request else {
    426             panic!("expected order cancel request")
    427         };
    428         assert_eq!(request.operation_id(), "order.cancel");
    429         assert_eq!(
    430             request
    431                 .payload
    432                 .input
    433                 .get("order_id")
    434                 .and_then(Value::as_str),
    435             Some("ord_test")
    436         );
    437         assert_eq!(
    438             request.payload.input.get("reason").and_then(Value::as_str),
    439             Some("changed plans")
    440         );
    441     }
    442 
    443     #[test]
    444     fn typed_service_boundary_returns_enveloped_result() {
    445         struct WorkspaceService;
    446 
    447         impl OperationService<WorkspaceGetRequest> for WorkspaceService {
    448             type Result = WorkspaceGetResult;
    449 
    450             fn execute(
    451                 &self,
    452                 request: OperationRequest<WorkspaceGetRequest>,
    453             ) -> Result<OperationResult<Self::Result>, super::OperationAdapterError> {
    454                 assert_eq!(request.operation_id(), "workspace.get");
    455                 OperationResult::new(WorkspaceGetResult::default())
    456             }
    457         }
    458 
    459         let adapter = OperationAdapter::new(WorkspaceService);
    460         let context = OperationContext::default();
    461         let request = OperationRequest::new(context.clone(), WorkspaceGetRequest::default())
    462             .expect("typed request");
    463         let result = adapter.execute(request).expect("typed result");
    464         let envelope = result
    465             .to_envelope(context.envelope_context("req_test"))
    466             .expect("operation envelope");
    467 
    468         assert_eq!(envelope.operation_id, "workspace.get");
    469         assert_eq!(envelope.kind, "workspace.get");
    470         assert_eq!(envelope.request_id, "req_test");
    471         assert_eq!(envelope.result, json!({}));
    472     }
    473 
    474     #[test]
    475     fn approval_errors_map_to_structured_exit_code() {
    476         let error = OperationAdapterError::approval_required("order.submit");
    477         let output_error = error.to_output_error();
    478 
    479         assert_eq!(output_error.code, "approval_required");
    480         assert_eq!(output_error.exit_code, 6);
    481         assert!(output_error.message.contains("approval_token"));
    482     }
    483 
    484     #[test]
    485     fn not_implemented_errors_map_to_structured_exit_code() {
    486         let error =
    487             OperationAdapterError::not_implemented("test.operation", "coming soon".to_owned());
    488         let output_error = error.to_output_error();
    489 
    490         assert_eq!(output_error.code, "not_implemented");
    491         assert_eq!(output_error.exit_code, 3);
    492         assert_eq!(
    493             output_error.detail.expect("detail")["operation_id"],
    494             "test.operation"
    495         );
    496     }
    497 
    498     #[test]
    499     fn runtime_failures_map_to_specific_machine_codes() {
    500         let cases = [
    501             (
    502                 OperationAdapterError::unconfigured(
    503                     "listing.publish",
    504                     "no selected account for seller write".to_owned(),
    505                 ),
    506                 "account_unresolved",
    507                 "account",
    508                 5,
    509             ),
    510             (
    511                 OperationAdapterError::unconfigured(
    512                     "listing.publish",
    513                     "resolved account `a` is watch_only and cannot sign because it is not secret-backed"
    514                         .to_owned(),
    515                 ),
    516                 "account_watch_only",
    517                 "account",
    518                 7,
    519             ),
    520             (
    521                 OperationAdapterError::unconfigured(
    522                     "listing.publish",
    523                     "account mismatch: resolved account pubkey `b` cannot sign listing seller_pubkey `a`"
    524                         .to_owned(),
    525                 ),
    526                 "account_mismatch",
    527                 "account",
    528                 5,
    529             ),
    530             (
    531                 OperationAdapterError::unconfigured(
    532                     "listing.publish",
    533                     "signer.remote_nip46 binding is missing".to_owned(),
    534                 ),
    535                 "signer_unconfigured",
    536                 "signer",
    537                 7,
    538             ),
    539             (
    540                 OperationAdapterError::unavailable(
    541                     "listing.publish",
    542                     "radrootsd bridge is unavailable".to_owned(),
    543                 ),
    544                 "provider_unavailable",
    545                 "provider",
    546                 3,
    547             ),
    548             (
    549                 OperationAdapterError::SignerModeDeferred {
    550                     operation_id: "signer.status.get".to_owned(),
    551                     message: "signer mode `myc` is deferred".to_owned(),
    552                 },
    553                 "signer_mode_deferred",
    554                 "signer",
    555                 7,
    556             ),
    557             (
    558                 OperationAdapterError::unconfigured(
    559                     "basket.quote.create",
    560                     "quote engine not ready".to_owned(),
    561                 ),
    562                 "operation_unavailable",
    563                 "operation",
    564                 3,
    565             ),
    566             (
    567                 OperationAdapterError::runtime_failure(
    568                     "listing.publish",
    569                     RuntimeError::Io(io::Error::new(io::ErrorKind::NotFound, "missing draft")),
    570                 ),
    571                 "not_found",
    572                 "resource",
    573                 4,
    574             ),
    575             (
    576                 OperationAdapterError::runtime_failure(
    577                     "listing.validate",
    578                     RuntimeError::Config("invalid listing draft listing.toml".to_owned()),
    579                 ),
    580                 "validation_failed",
    581                 "validation",
    582                 10,
    583             ),
    584             (
    585                 OperationAdapterError::runtime_failure(
    586                     "listing.archive",
    587                     RuntimeError::Account(AccountRuntimeFailure::mismatch(
    588                         "account mismatch: resolved account pubkey `b` cannot sign listing seller_pubkey `a`",
    589                     )),
    590                 ),
    591                 "account_mismatch",
    592                 "account",
    593                 5,
    594             ),
    595             (
    596                 OperationAdapterError::runtime_failure(
    597                     "farm.publish",
    598                     RuntimeError::Network("direct relay connection failed".to_owned()),
    599                 ),
    600                 "network_unavailable",
    601                 "network",
    602                 8,
    603             ),
    604         ];
    605 
    606         for (error, code, class, exit_code) in cases {
    607             let output = error.to_output_error();
    608             assert_eq!(output.code, code);
    609             assert_eq!(output.exit_code, exit_code);
    610             assert_eq!(
    611                 output.detail.expect("detail")["class"],
    612                 serde_json::Value::String(class.to_owned())
    613             );
    614         }
    615     }
    616 }