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 }