cli

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

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 }