mod.rs (19496B)
1 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 2 pub struct OperationSpec { 3 pub operation_id: &'static str, 4 pub cli_path: &'static str, 5 pub namespace: &'static str, 6 pub mcp_tool: &'static str, 7 pub rust_request: &'static str, 8 pub rust_result: &'static str, 9 pub json_kind: &'static str, 10 pub description: &'static str, 11 pub role: OperationRole, 12 pub mutates: bool, 13 pub approval_policy: ApprovalPolicy, 14 pub risk_level: RiskLevel, 15 pub supports_json: bool, 16 pub supports_ndjson: bool, 17 pub supports_dry_run: bool, 18 } 19 20 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 21 pub enum ApprovalPolicy { 22 None, 23 Conditional, 24 Required, 25 } 26 27 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 28 pub enum RiskLevel { 29 Low, 30 Medium, 31 High, 32 Critical, 33 } 34 35 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 36 pub enum OperationRole { 37 Any, 38 Buyer, 39 Seller, 40 } 41 42 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 43 pub enum NetworkRequirement { 44 Local, 45 External { dry_run_requires_network: bool }, 46 } 47 48 macro_rules! operation { 49 ( 50 $operation_id:literal, 51 $cli_path:literal, 52 $namespace:literal, 53 $mcp_tool:literal, 54 $rust_request:literal, 55 $rust_result:literal, 56 $description:literal, 57 $role:ident, 58 $mutates:literal, 59 $approval_policy:ident, 60 $risk_level:ident, 61 $supports_ndjson:literal, 62 $supports_dry_run:literal 63 ) => { 64 OperationSpec { 65 operation_id: $operation_id, 66 cli_path: $cli_path, 67 namespace: $namespace, 68 mcp_tool: $mcp_tool, 69 rust_request: $rust_request, 70 rust_result: $rust_result, 71 json_kind: $operation_id, 72 description: $description, 73 role: OperationRole::$role, 74 mutates: $mutates, 75 approval_policy: ApprovalPolicy::$approval_policy, 76 risk_level: RiskLevel::$risk_level, 77 supports_json: true, 78 supports_ndjson: $supports_ndjson, 79 supports_dry_run: $supports_dry_run, 80 } 81 }; 82 } 83 84 mod account; 85 mod basket; 86 mod config; 87 mod farm; 88 mod health; 89 mod listing; 90 mod market; 91 mod order; 92 mod relay; 93 mod signer; 94 mod store; 95 mod sync; 96 mod validation; 97 mod workspace; 98 99 pub const OPERATION_REGISTRY: &[OperationSpec] = &[ 100 workspace::WORKSPACE_INIT, 101 workspace::WORKSPACE_GET, 102 health::HEALTH_STATUS_GET, 103 health::HEALTH_CHECK_RUN, 104 config::CONFIG_GET, 105 account::ACCOUNT_CREATE, 106 account::ACCOUNT_IMPORT, 107 account::ACCOUNT_ATTACH_SECRET, 108 account::ACCOUNT_GET, 109 account::ACCOUNT_LIST, 110 account::ACCOUNT_REMOVE, 111 account::ACCOUNT_SELECTION_GET, 112 account::ACCOUNT_SELECTION_UPDATE, 113 account::ACCOUNT_SELECTION_CLEAR, 114 signer::SIGNER_STATUS_GET, 115 relay::RELAY_LIST, 116 store::STORE_INIT, 117 store::STORE_STATUS_GET, 118 store::STORE_EXPORT, 119 store::STORE_BACKUP_CREATE, 120 store::STORE_BACKUP_RESTORE, 121 sync::SYNC_STATUS_GET, 122 sync::SYNC_PULL, 123 sync::SYNC_PUSH, 124 sync::SYNC_WATCH, 125 farm::FARM_CREATE, 126 farm::FARM_GET, 127 farm::FARM_REBIND, 128 farm::FARM_PROFILE_UPDATE, 129 farm::FARM_LOCATION_UPDATE, 130 farm::FARM_FULFILLMENT_UPDATE, 131 farm::FARM_READINESS_CHECK, 132 farm::FARM_PUBLISH, 133 listing::LISTING_CREATE, 134 listing::LISTING_GET, 135 listing::LISTING_LIST, 136 listing::LISTING_APP_LIST, 137 listing::LISTING_APP_EXPORT, 138 listing::LISTING_UPDATE, 139 listing::LISTING_VALIDATE, 140 listing::LISTING_REBIND, 141 listing::LISTING_PUBLISH, 142 listing::LISTING_ARCHIVE, 143 market::MARKET_REFRESH, 144 market::MARKET_PRODUCT_SEARCH, 145 market::MARKET_LISTING_GET, 146 basket::BASKET_CREATE, 147 basket::BASKET_GET, 148 basket::BASKET_LIST, 149 basket::BASKET_ITEM_ADD, 150 basket::BASKET_ITEM_UPDATE, 151 basket::BASKET_ITEM_REMOVE, 152 basket::BASKET_ADJUSTMENT_ADD, 153 basket::BASKET_ADJUSTMENT_REMOVE, 154 basket::BASKET_VALIDATE, 155 basket::BASKET_QUOTE_CREATE, 156 order::ORDER_SUBMIT, 157 order::ORDER_GET, 158 order::ORDER_LIST, 159 order::ORDER_APP_LIST, 160 order::ORDER_APP_EXPORT, 161 order::ORDER_REBIND, 162 order::ORDER_ACCEPT, 163 order::ORDER_DECLINE, 164 order::ORDER_CANCEL, 165 order::ORDER_REVISION_PROPOSE, 166 order::ORDER_REVISION_ACCEPT, 167 order::ORDER_REVISION_DECLINE, 168 order::ORDER_STATUS_GET, 169 order::ORDER_EVENT_LIST, 170 order::ORDER_EVENT_WATCH, 171 validation::VALIDATION_RECEIPT_GET, 172 validation::VALIDATION_RECEIPT_LIST, 173 validation::VALIDATION_RECEIPT_VERIFY, 174 ]; 175 176 pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { 177 OPERATION_REGISTRY 178 .iter() 179 .find(|operation| operation.operation_id == operation_id) 180 } 181 182 pub fn network_requirement(operation_id: &str) -> NetworkRequirement { 183 match operation_id { 184 "sync.pull" 185 | "sync.push" 186 | "sync.watch" 187 | "market.refresh" 188 | "farm.publish" 189 | "listing.publish" 190 | "listing.update" 191 | "listing.archive" 192 | "order.submit" 193 | "order.event.list" 194 | "validation.receipt.get" 195 | "validation.receipt.list" 196 | "validation.receipt.verify" => NetworkRequirement::External { 197 dry_run_requires_network: false, 198 }, 199 "order.accept" 200 | "order.decline" 201 | "order.cancel" 202 | "order.revision.propose" 203 | "order.revision.accept" 204 | "order.revision.decline" => NetworkRequirement::External { 205 dry_run_requires_network: true, 206 }, 207 _ => NetworkRequirement::Local, 208 } 209 } 210 211 pub fn requires_local_signer_mode(operation_id: &str) -> bool { 212 matches!( 213 operation_id, 214 "sync.push" 215 | "order.submit" 216 | "order.accept" 217 | "order.decline" 218 | "order.cancel" 219 | "order.revision.propose" 220 | "order.revision.accept" 221 | "order.revision.decline" 222 ) 223 } 224 225 #[cfg(test)] 226 pub fn requires_direct_nostr_relay_publish_transport(operation_id: &str) -> bool { 227 matches!( 228 operation_id, 229 "sync.push" 230 | "farm.publish" 231 | "listing.publish" 232 | "listing.update" 233 | "listing.archive" 234 | "order.submit" 235 | "order.accept" 236 | "order.decline" 237 | "order.cancel" 238 | "order.revision.propose" 239 | "order.revision.accept" 240 | "order.revision.decline" 241 ) 242 } 243 244 pub fn registry_linkage_is_valid() -> bool { 245 OPERATION_REGISTRY.iter().all(|operation| { 246 get_operation(operation.operation_id).is_some() 247 && operation.operation_id == operation.json_kind 248 && operation.mcp_tool == operation.operation_id.replace('.', "_") 249 && operation.supports_json 250 }) 251 } 252 253 #[cfg(test)] 254 mod tests { 255 use std::collections::BTreeSet; 256 257 use super::{ 258 ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, OperationRole, RiskLevel, 259 get_operation, network_requirement, requires_direct_nostr_relay_publish_transport, 260 requires_local_signer_mode, 261 }; 262 263 const EXPECTED_OPERATION_IDS: &[&str] = &[ 264 "workspace.init", 265 "workspace.get", 266 "health.status.get", 267 "health.check.run", 268 "config.get", 269 "account.create", 270 "account.import", 271 "account.attach_secret", 272 "account.get", 273 "account.list", 274 "account.remove", 275 "account.selection.get", 276 "account.selection.update", 277 "account.selection.clear", 278 "signer.status.get", 279 "relay.list", 280 "store.init", 281 "store.status.get", 282 "store.export", 283 "store.backup.create", 284 "store.backup.restore", 285 "sync.status.get", 286 "sync.pull", 287 "sync.push", 288 "sync.watch", 289 "farm.create", 290 "farm.get", 291 "farm.rebind", 292 "farm.profile.update", 293 "farm.location.update", 294 "farm.fulfillment.update", 295 "farm.readiness.check", 296 "farm.publish", 297 "listing.create", 298 "listing.get", 299 "listing.list", 300 "listing.app.list", 301 "listing.app.export", 302 "listing.update", 303 "listing.validate", 304 "listing.rebind", 305 "listing.publish", 306 "listing.archive", 307 "market.refresh", 308 "market.product.search", 309 "market.listing.get", 310 "basket.create", 311 "basket.get", 312 "basket.list", 313 "basket.item.add", 314 "basket.item.update", 315 "basket.item.remove", 316 "basket.adjustment.add", 317 "basket.adjustment.remove", 318 "basket.validate", 319 "basket.quote.create", 320 "order.submit", 321 "order.get", 322 "order.list", 323 "order.app.list", 324 "order.app.export", 325 "order.rebind", 326 "order.accept", 327 "order.decline", 328 "order.cancel", 329 "order.revision.propose", 330 "order.revision.accept", 331 "order.revision.decline", 332 "order.status.get", 333 "order.event.list", 334 "order.event.watch", 335 "validation.receipt.get", 336 "validation.receipt.list", 337 "validation.receipt.verify", 338 ]; 339 340 const SUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[ 341 "workspace.init", 342 "account.create", 343 "account.import", 344 "account.attach_secret", 345 "account.remove", 346 "account.selection.update", 347 "account.selection.clear", 348 "store.init", 349 "store.backup.create", 350 "store.backup.restore", 351 "sync.pull", 352 "sync.push", 353 "farm.create", 354 "farm.rebind", 355 "farm.profile.update", 356 "farm.location.update", 357 "farm.fulfillment.update", 358 "farm.publish", 359 "listing.create", 360 "listing.app.export", 361 "listing.update", 362 "listing.rebind", 363 "listing.publish", 364 "listing.archive", 365 "market.refresh", 366 "basket.create", 367 "basket.item.add", 368 "basket.item.update", 369 "basket.item.remove", 370 "basket.adjustment.add", 371 "basket.adjustment.remove", 372 "basket.quote.create", 373 "order.submit", 374 "order.app.export", 375 "order.rebind", 376 "order.accept", 377 "order.decline", 378 "order.cancel", 379 "order.revision.propose", 380 "order.revision.accept", 381 "order.revision.decline", 382 ]; 383 384 const INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS: &[&str] = &[]; 385 386 #[test] 387 fn registry_contains_exact_target_operation_set() { 388 let actual = operation_ids(); 389 let expected = EXPECTED_OPERATION_IDS 390 .iter() 391 .copied() 392 .collect::<BTreeSet<_>>(); 393 assert_eq!(actual, expected); 394 assert_eq!(OPERATION_REGISTRY.len(), 74); 395 } 396 397 #[test] 398 fn registry_identity_fields_are_consistent() { 399 let mut operation_ids = BTreeSet::new(); 400 let mut cli_paths = BTreeSet::new(); 401 let mut mcp_tools = BTreeSet::new(); 402 403 for operation in OPERATION_REGISTRY { 404 assert!(operation_ids.insert(operation.operation_id)); 405 assert!(cli_paths.insert(operation.cli_path)); 406 assert!(mcp_tools.insert(operation.mcp_tool)); 407 assert_eq!(operation.operation_id, operation.json_kind); 408 assert_eq!(operation.mcp_tool, operation.operation_id.replace('.', "_")); 409 assert!(operation.cli_path.starts_with("radroots ")); 410 assert_eq!( 411 operation.namespace, 412 operation.operation_id.split('.').next().unwrap() 413 ); 414 assert_eq!( 415 operation.rust_request, 416 format!("{}Request", pascal_case(operation.operation_id)) 417 ); 418 assert_eq!( 419 operation.rust_result, 420 format!("{}Result", pascal_case(operation.operation_id)) 421 ); 422 assert!(operation.supports_json); 423 assert!(!operation.description.is_empty()); 424 } 425 } 426 427 #[test] 428 fn registry_policy_invariants_hold() { 429 let required = OPERATION_REGISTRY 430 .iter() 431 .filter(|operation| operation.approval_policy == ApprovalPolicy::Required) 432 .map(|operation| operation.operation_id) 433 .collect::<BTreeSet<_>>(); 434 let expected_required = [ 435 "account.import", 436 "account.attach_secret", 437 "account.remove", 438 "sync.push", 439 "farm.rebind", 440 "farm.publish", 441 "listing.rebind", 442 "listing.publish", 443 "listing.update", 444 "listing.archive", 445 "order.submit", 446 "order.rebind", 447 "order.accept", 448 "order.decline", 449 "order.cancel", 450 "order.revision.propose", 451 "order.revision.accept", 452 "order.revision.decline", 453 ] 454 .into_iter() 455 .collect::<BTreeSet<_>>(); 456 457 assert_eq!(required, expected_required); 458 459 for operation in OPERATION_REGISTRY { 460 if operation.mutates { 461 assert!(operation.supports_dry_run, "{}", operation.operation_id); 462 } 463 464 if operation.approval_policy == ApprovalPolicy::Required { 465 assert!( 466 matches!(operation.risk_level, RiskLevel::High | RiskLevel::Critical), 467 "{}", 468 operation.operation_id 469 ); 470 } 471 } 472 } 473 474 #[test] 475 fn mutating_dry_run_registry_inventory_is_complete() { 476 let advertised = OPERATION_REGISTRY 477 .iter() 478 .filter(|operation| operation.mutates && operation.supports_dry_run) 479 .map(|operation| operation.operation_id) 480 .collect::<BTreeSet<_>>(); 481 let supported = SUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS 482 .iter() 483 .copied() 484 .collect::<BTreeSet<_>>(); 485 let unsupported = INTENTIONALLY_UNSUPPORTED_MUTATING_DRY_RUN_OPERATION_IDS 486 .iter() 487 .copied() 488 .collect::<BTreeSet<_>>(); 489 let classified = supported 490 .union(&unsupported) 491 .copied() 492 .collect::<BTreeSet<_>>(); 493 494 assert_eq!(advertised, classified); 495 assert!(supported.is_disjoint(&unsupported)); 496 } 497 498 #[test] 499 fn registry_ndjson_support_is_explicit() { 500 let actual = OPERATION_REGISTRY 501 .iter() 502 .filter(|operation| operation.supports_ndjson) 503 .map(|operation| operation.operation_id) 504 .collect::<BTreeSet<_>>(); 505 let expected = [ 506 "health.status.get", 507 "health.check.run", 508 "config.get", 509 "account.list", 510 "relay.list", 511 "sync.pull", 512 "sync.push", 513 "sync.watch", 514 "listing.list", 515 "market.refresh", 516 "market.product.search", 517 "basket.list", 518 "order.list", 519 "order.event.list", 520 "validation.receipt.list", 521 ] 522 .into_iter() 523 .collect::<BTreeSet<_>>(); 524 525 assert_eq!(actual, expected); 526 } 527 528 #[test] 529 fn registry_network_requirements_are_explicit() { 530 let external = OPERATION_REGISTRY 531 .iter() 532 .filter(|operation| { 533 matches!( 534 network_requirement(operation.operation_id), 535 NetworkRequirement::External { .. } 536 ) 537 }) 538 .map(|operation| operation.operation_id) 539 .collect::<BTreeSet<_>>(); 540 let expected = [ 541 "sync.pull", 542 "sync.push", 543 "sync.watch", 544 "market.refresh", 545 "farm.publish", 546 "listing.publish", 547 "listing.update", 548 "listing.archive", 549 "order.submit", 550 "order.accept", 551 "order.decline", 552 "order.cancel", 553 "order.revision.propose", 554 "order.revision.accept", 555 "order.revision.decline", 556 "order.event.list", 557 "validation.receipt.get", 558 "validation.receipt.list", 559 "validation.receipt.verify", 560 ] 561 .into_iter() 562 .collect::<BTreeSet<_>>(); 563 564 assert_eq!(external, expected); 565 } 566 567 #[test] 568 fn registry_local_signer_requirements_are_explicit() { 569 let signed = OPERATION_REGISTRY 570 .iter() 571 .filter(|operation| requires_local_signer_mode(operation.operation_id)) 572 .map(|operation| operation.operation_id) 573 .collect::<BTreeSet<_>>(); 574 let expected = [ 575 "sync.push", 576 "order.submit", 577 "order.accept", 578 "order.decline", 579 "order.cancel", 580 "order.revision.propose", 581 "order.revision.accept", 582 "order.revision.decline", 583 ] 584 .into_iter() 585 .collect::<BTreeSet<_>>(); 586 587 assert_eq!(signed, expected); 588 } 589 590 #[test] 591 fn registry_direct_nostr_relay_publish_requirements_are_explicit() { 592 let publish = OPERATION_REGISTRY 593 .iter() 594 .filter(|operation| { 595 requires_direct_nostr_relay_publish_transport(operation.operation_id) 596 }) 597 .map(|operation| operation.operation_id) 598 .collect::<BTreeSet<_>>(); 599 let expected = [ 600 "sync.push", 601 "farm.publish", 602 "listing.publish", 603 "listing.update", 604 "listing.archive", 605 "order.submit", 606 "order.accept", 607 "order.decline", 608 "order.cancel", 609 "order.revision.propose", 610 "order.revision.accept", 611 "order.revision.decline", 612 ] 613 .into_iter() 614 .collect::<BTreeSet<_>>(); 615 616 assert_eq!(publish, expected); 617 } 618 619 #[test] 620 fn deferred_namespaces_are_absent() { 621 let namespaces = OPERATION_REGISTRY 622 .iter() 623 .map(|operation| operation.namespace) 624 .collect::<BTreeSet<_>>(); 625 626 assert!(!namespaces.contains("product")); 627 assert!(!namespaces.contains("message")); 628 assert!(!namespaces.contains("approval")); 629 assert!(!namespaces.contains("agent")); 630 assert!(!namespaces.contains("runtime")); 631 assert!(!namespaces.contains("job")); 632 } 633 634 #[test] 635 fn roles_are_assigned_to_marketplace_operations() { 636 assert_eq!( 637 get_operation("listing.publish").unwrap().role, 638 OperationRole::Seller 639 ); 640 assert_eq!( 641 get_operation("basket.quote.create").unwrap().role, 642 OperationRole::Buyer 643 ); 644 assert_eq!( 645 get_operation("order.list").unwrap().role, 646 OperationRole::Any 647 ); 648 } 649 650 fn operation_ids() -> BTreeSet<&'static str> { 651 OPERATION_REGISTRY 652 .iter() 653 .map(|operation| operation.operation_id) 654 .collect() 655 } 656 657 fn pascal_case(operation_id: &str) -> String { 658 operation_id 659 .split('.') 660 .flat_map(|part| part.split('_')) 661 .map(|part| { 662 let mut chars = part.chars(); 663 let first = chars.next().unwrap().to_ascii_uppercase(); 664 format!("{first}{}", chars.as_str()) 665 }) 666 .collect() 667 } 668 }