cli

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

commit 0603b9806d5ff29b833e4bdd1704366abc6e4892
parent 3d5467ed2dd35af8c1214262a0c7cbbd323bd9e3
Author: triesap <tyson@radroots.org>
Date:   Sun, 26 Apr 2026 22:56:08 +0000

cli: add operation adapter boundary

- add typed MVP operation requests and results bound to the registry
- carry target global flags through a parser-independent operation context
- introduce a generic service trait for incremental command migration
- verify parser, registry, service, and envelope linkage in adapter tests

Diffstat:
Msrc/main.rs | 2++
Asrc/operation_adapter.rs | 573+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 575 insertions(+), 0 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -3,6 +3,7 @@ mod cli; mod commands; mod domain; +mod operation_adapter; mod operation_registry; mod output_contract; mod render; @@ -30,6 +31,7 @@ fn main() -> ExitCode { fn run() -> Result<ExitCode, runtime::RuntimeError> { debug_assert!(operation_registry::registry_linkage_is_valid()); + debug_assert!(operation_adapter::adapter_registry_linkage_is_valid()); let args = CliArgs::parse(); let config = RuntimeConfig::from_system(&args)?; validate_command_contracts(&args.command, &config)?; diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -0,0 +1,573 @@ +#![allow(dead_code)] + +use std::fmt::Debug; + +use serde::Serialize; + +use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation}; +use crate::output_contract::{ + EnvelopeContext, NextAction, OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputWarning, +}; +use crate::target_cli::{TargetCliArgs, TargetOutputFormat}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationOutputFormat { + Human, + Json, + Ndjson, +} + +impl Default for OperationOutputFormat { + fn default() -> Self { + Self::Human + } +} + +impl From<TargetOutputFormat> for OperationOutputFormat { + fn from(format: TargetOutputFormat) -> Self { + match format { + TargetOutputFormat::Human => Self::Human, + TargetOutputFormat::Json => Self::Json, + TargetOutputFormat::Ndjson => Self::Ndjson, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationNetworkMode { + Default, + Offline, + Online, +} + +impl Default for OperationNetworkMode { + fn default() -> Self { + Self::Default + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationInputMode { + PromptingAllowed, + NoInput, +} + +impl Default for OperationInputMode { + fn default() -> Self { + Self::PromptingAllowed + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct OperationContext { + pub output_format: OperationOutputFormat, + pub account_id: Option<String>, + pub farm_id: Option<String>, + pub profile: Option<String>, + pub signer_session_id: Option<String>, + pub relays: Vec<String>, + pub network_mode: OperationNetworkMode, + pub dry_run: bool, + pub idempotency_key: Option<String>, + pub correlation_id: Option<String>, + pub approval_token: Option<String>, + pub input_mode: OperationInputMode, + pub quiet: bool, + pub verbose: bool, + pub trace: bool, + pub color: bool, +} + +impl OperationContext { + pub fn from_target_args(args: &TargetCliArgs) -> Self { + Self { + output_format: OperationOutputFormat::from(args.format), + account_id: args.account_id.clone(), + farm_id: args.farm_id.clone(), + profile: args.profile.clone(), + signer_session_id: args.signer_session_id.clone(), + relays: args.relay.clone(), + network_mode: if args.offline { + OperationNetworkMode::Offline + } else if args.online { + OperationNetworkMode::Online + } else { + OperationNetworkMode::Default + }, + dry_run: args.dry_run, + idempotency_key: args.idempotency_key.clone(), + correlation_id: args.correlation_id.clone(), + approval_token: args.approval_token.clone(), + input_mode: if args.no_input { + OperationInputMode::NoInput + } else { + OperationInputMode::PromptingAllowed + }, + quiet: args.quiet, + verbose: args.verbose, + trace: args.trace, + color: !args.no_color, + } + } + + pub fn envelope_context(&self, request_id: impl Into<String>) -> EnvelopeContext { + let mut context = EnvelopeContext::new(request_id, self.dry_run); + context.correlation_id = self.correlation_id.clone(); + context.idempotency_key = self.idempotency_key.clone(); + context + } +} + +pub trait OperationRequestPayload: Debug + Clone + PartialEq + Eq + 'static { + const OPERATION_ID: &'static str; + const REQUEST_TYPE: &'static str; +} + +pub trait OperationResultPayload: Debug + Clone + PartialEq + Eq + Serialize + 'static { + const OPERATION_ID: &'static str; + const RESULT_TYPE: &'static str; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationRequest<P: OperationRequestPayload> { + pub spec: &'static OperationSpec, + pub context: OperationContext, + pub payload: P, +} + +impl<P: OperationRequestPayload> OperationRequest<P> { + pub fn new(context: OperationContext, payload: P) -> Result<Self, OperationAdapterError> { + let spec = get_operation(P::OPERATION_ID) + .ok_or_else(|| OperationAdapterError::UnknownOperation(P::OPERATION_ID.to_owned()))?; + if spec.rust_request != P::REQUEST_TYPE { + return Err(OperationAdapterError::RequestTypeMismatch { + operation_id: P::OPERATION_ID.to_owned(), + registry_request: spec.rust_request.to_owned(), + adapter_request: P::REQUEST_TYPE.to_owned(), + }); + } + Ok(Self { + spec, + context, + payload, + }) + } + + pub fn operation_id(&self) -> &'static str { + P::OPERATION_ID + } + + pub fn request_type_name(&self) -> &'static str { + P::REQUEST_TYPE + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OperationResult<P: OperationResultPayload> { + pub spec: &'static OperationSpec, + pub payload: P, + pub warnings: Vec<OutputWarning>, + pub next_actions: Vec<NextAction>, +} + +impl<P: OperationResultPayload> OperationResult<P> { + pub fn new(payload: P) -> Result<Self, OperationAdapterError> { + let spec = get_operation(P::OPERATION_ID) + .ok_or_else(|| OperationAdapterError::UnknownOperation(P::OPERATION_ID.to_owned()))?; + if spec.rust_result != P::RESULT_TYPE { + return Err(OperationAdapterError::ResultTypeMismatch { + operation_id: P::OPERATION_ID.to_owned(), + registry_result: spec.rust_result.to_owned(), + adapter_result: P::RESULT_TYPE.to_owned(), + }); + } + Ok(Self { + spec, + payload, + warnings: Vec::new(), + next_actions: Vec::new(), + }) + } + + pub fn operation_id(&self) -> &'static str { + P::OPERATION_ID + } + + pub fn result_type_name(&self) -> &'static str { + P::RESULT_TYPE + } + + pub fn to_envelope( + &self, + context: EnvelopeContext, + ) -> Result<OutputEnvelope, OperationAdapterError> { + Ok(OutputEnvelope { + schema_version: OUTPUT_SCHEMA_VERSION, + operation_id: self.operation_id().to_owned(), + kind: self.operation_id().to_owned(), + request_id: context.request_id, + correlation_id: context.correlation_id, + idempotency_key: context.idempotency_key, + dry_run: context.dry_run, + actor: context.actor, + result: serde_json::to_value(&self.payload) + .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?, + warnings: self.warnings.clone(), + errors: Vec::new(), + next_actions: self.next_actions.clone(), + }) + } +} + +pub trait OperationService<P: OperationRequestPayload> { + type Result: OperationResultPayload; + + fn execute( + &self, + request: OperationRequest<P>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError>; +} + +#[derive(Debug, Clone)] +pub struct OperationAdapter<S> { + service: S, +} + +impl<S> OperationAdapter<S> { + pub fn new(service: S) -> Self { + Self { service } + } + + pub fn execute<P>( + &self, + request: OperationRequest<P>, + ) -> Result<OperationResult<<S as OperationService<P>>::Result>, OperationAdapterError> + where + P: OperationRequestPayload, + S: OperationService<P>, + { + self.service.execute(request) + } +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum OperationAdapterError { + #[error("unknown operation `{0}`")] + UnknownOperation(String), + #[error( + "operation `{operation_id}` registry request `{registry_request}` does not match adapter request `{adapter_request}`" + )] + RequestTypeMismatch { + operation_id: String, + registry_request: String, + adapter_request: String, + }, + #[error( + "operation `{operation_id}` registry result `{registry_result}` does not match adapter result `{adapter_result}`" + )] + ResultTypeMismatch { + operation_id: String, + registry_result: String, + adapter_result: String, + }, + #[error("failed to serialize operation result: {0}")] + Serialization(String), +} + +macro_rules! mvp_operation_contracts { + ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { + #[derive(Debug, Clone, PartialEq, Eq)] + pub enum MvpOperationRequest { + $( $variant(OperationRequest<$request>), )+ + } + + impl MvpOperationRequest { + pub fn from_target_args(args: &TargetCliArgs) -> Result<Self, OperationAdapterError> { + Self::from_operation_id(args.command.operation_id(), OperationContext::from_target_args(args)) + } + + pub fn from_operation_id( + operation_id: &'static str, + context: OperationContext, + ) -> Result<Self, OperationAdapterError> { + match operation_id { + $( $operation_id => Ok(Self::$variant(OperationRequest::new(context, $request::default())?)), )+ + _ => Err(OperationAdapterError::UnknownOperation(operation_id.to_owned())), + } + } + + pub fn operation_id(&self) -> &'static str { + match self { + $( Self::$variant(request) => request.operation_id(), )+ + } + } + + pub fn spec(&self) -> &'static OperationSpec { + match self { + $( Self::$variant(request) => request.spec, )+ + } + } + + pub fn context(&self) -> &OperationContext { + match self { + $( Self::$variant(request) => &request.context, )+ + } + } + + pub fn request_type_name(&self) -> &'static str { + match self { + $( Self::$variant(request) => request.request_type_name(), )+ + } + } + + pub fn request_type_for_operation(operation_id: &str) -> Option<&'static str> { + match operation_id { + $( $operation_id => Some(stringify!($request)), )+ + _ => None, + } + } + } + + #[derive(Debug, Clone, PartialEq)] + pub enum MvpOperationResult { + $( $variant(OperationResult<$result>), )+ + } + + impl MvpOperationResult { + pub fn operation_id(&self) -> &'static str { + match self { + $( Self::$variant(result) => result.operation_id(), )+ + } + } + + pub fn result_type_name(&self) -> &'static str { + match self { + $( Self::$variant(result) => result.result_type_name(), )+ + } + } + + pub fn result_type_for_operation(operation_id: &str) -> Option<&'static str> { + match operation_id { + $( $operation_id => Some(stringify!($result)), )+ + _ => None, + } + } + } + + $( + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] + pub struct $request {} + + impl OperationRequestPayload for $request { + const OPERATION_ID: &'static str = $operation_id; + const REQUEST_TYPE: &'static str = stringify!($request); + } + + #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] + pub struct $result {} + + impl OperationResultPayload for $result { + const OPERATION_ID: &'static str = $operation_id; + const RESULT_TYPE: &'static str = stringify!($result); + } + )+ + }; +} + +mvp_operation_contracts! { + WorkspaceInit => (WorkspaceInitRequest, WorkspaceInitResult, "workspace.init"), + WorkspaceGet => (WorkspaceGetRequest, WorkspaceGetResult, "workspace.get"), + HealthStatusGet => (HealthStatusGetRequest, HealthStatusGetResult, "health.status.get"), + HealthCheckRun => (HealthCheckRunRequest, HealthCheckRunResult, "health.check.run"), + ConfigGet => (ConfigGetRequest, ConfigGetResult, "config.get"), + AccountCreate => (AccountCreateRequest, AccountCreateResult, "account.create"), + AccountImport => (AccountImportRequest, AccountImportResult, "account.import"), + AccountGet => (AccountGetRequest, AccountGetResult, "account.get"), + AccountList => (AccountListRequest, AccountListResult, "account.list"), + AccountRemove => (AccountRemoveRequest, AccountRemoveResult, "account.remove"), + AccountSelectionGet => (AccountSelectionGetRequest, AccountSelectionGetResult, "account.selection.get"), + AccountSelectionUpdate => (AccountSelectionUpdateRequest, AccountSelectionUpdateResult, "account.selection.update"), + AccountSelectionClear => (AccountSelectionClearRequest, AccountSelectionClearResult, "account.selection.clear"), + SignerStatusGet => (SignerStatusGetRequest, SignerStatusGetResult, "signer.status.get"), + RelayList => (RelayListRequest, RelayListResult, "relay.list"), + StoreInit => (StoreInitRequest, StoreInitResult, "store.init"), + StoreStatusGet => (StoreStatusGetRequest, StoreStatusGetResult, "store.status.get"), + StoreExport => (StoreExportRequest, StoreExportResult, "store.export"), + StoreBackupCreate => (StoreBackupCreateRequest, StoreBackupCreateResult, "store.backup.create"), + SyncStatusGet => (SyncStatusGetRequest, SyncStatusGetResult, "sync.status.get"), + SyncPull => (SyncPullRequest, SyncPullResult, "sync.pull"), + SyncPush => (SyncPushRequest, SyncPushResult, "sync.push"), + SyncWatch => (SyncWatchRequest, SyncWatchResult, "sync.watch"), + RuntimeStatusGet => (RuntimeStatusGetRequest, RuntimeStatusGetResult, "runtime.status.get"), + RuntimeStart => (RuntimeStartRequest, RuntimeStartResult, "runtime.start"), + RuntimeStop => (RuntimeStopRequest, RuntimeStopResult, "runtime.stop"), + RuntimeRestart => (RuntimeRestartRequest, RuntimeRestartResult, "runtime.restart"), + RuntimeLogWatch => (RuntimeLogWatchRequest, RuntimeLogWatchResult, "runtime.log.watch"), + RuntimeConfigGet => (RuntimeConfigGetRequest, RuntimeConfigGetResult, "runtime.config.get"), + JobGet => (JobGetRequest, JobGetResult, "job.get"), + JobList => (JobListRequest, JobListResult, "job.list"), + JobWatch => (JobWatchRequest, JobWatchResult, "job.watch"), + FarmCreate => (FarmCreateRequest, FarmCreateResult, "farm.create"), + FarmGet => (FarmGetRequest, FarmGetResult, "farm.get"), + FarmProfileUpdate => (FarmProfileUpdateRequest, FarmProfileUpdateResult, "farm.profile.update"), + FarmLocationUpdate => (FarmLocationUpdateRequest, FarmLocationUpdateResult, "farm.location.update"), + FarmFulfillmentUpdate => (FarmFulfillmentUpdateRequest, FarmFulfillmentUpdateResult, "farm.fulfillment.update"), + FarmReadinessCheck => (FarmReadinessCheckRequest, FarmReadinessCheckResult, "farm.readiness.check"), + FarmPublish => (FarmPublishRequest, FarmPublishResult, "farm.publish"), + ListingCreate => (ListingCreateRequest, ListingCreateResult, "listing.create"), + ListingGet => (ListingGetRequest, ListingGetResult, "listing.get"), + ListingList => (ListingListRequest, ListingListResult, "listing.list"), + ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"), + ListingValidate => (ListingValidateRequest, ListingValidateResult, "listing.validate"), + ListingPublish => (ListingPublishRequest, ListingPublishResult, "listing.publish"), + ListingArchive => (ListingArchiveRequest, ListingArchiveResult, "listing.archive"), + MarketRefresh => (MarketRefreshRequest, MarketRefreshResult, "market.refresh"), + MarketProductSearch => (MarketProductSearchRequest, MarketProductSearchResult, "market.product.search"), + MarketListingGet => (MarketListingGetRequest, MarketListingGetResult, "market.listing.get"), + BasketCreate => (BasketCreateRequest, BasketCreateResult, "basket.create"), + BasketGet => (BasketGetRequest, BasketGetResult, "basket.get"), + BasketList => (BasketListRequest, BasketListResult, "basket.list"), + BasketItemAdd => (BasketItemAddRequest, BasketItemAddResult, "basket.item.add"), + BasketItemUpdate => (BasketItemUpdateRequest, BasketItemUpdateResult, "basket.item.update"), + BasketItemRemove => (BasketItemRemoveRequest, BasketItemRemoveResult, "basket.item.remove"), + BasketValidate => (BasketValidateRequest, BasketValidateResult, "basket.validate"), + BasketQuoteCreate => (BasketQuoteCreateRequest, BasketQuoteCreateResult, "basket.quote.create"), + OrderSubmit => (OrderSubmitRequest, OrderSubmitResult, "order.submit"), + OrderGet => (OrderGetRequest, OrderGetResult, "order.get"), + OrderList => (OrderListRequest, OrderListResult, "order.list"), + OrderEventList => (OrderEventListRequest, OrderEventListResult, "order.event.list"), + OrderEventWatch => (OrderEventWatchRequest, OrderEventWatchResult, "order.event.watch"), +} + +pub fn adapter_registry_linkage_is_valid() -> bool { + OPERATION_REGISTRY.iter().all(|operation| { + MvpOperationRequest::request_type_for_operation(operation.operation_id) + == Some(operation.rust_request) + && MvpOperationResult::result_type_for_operation(operation.operation_id) + == Some(operation.rust_result) + }) +} + +#[cfg(test)] +mod tests { + use clap::Parser; + use serde_json::json; + + use super::{ + MvpOperationRequest, OperationAdapter, OperationContext, OperationInputMode, + OperationNetworkMode, OperationOutputFormat, OperationRequest, OperationResult, + OperationService, WorkspaceGetRequest, WorkspaceGetResult, + adapter_registry_linkage_is_valid, + }; + use crate::operation_registry::OPERATION_REGISTRY; + use crate::target_cli::TargetCliArgs; + + #[test] + fn adapter_binds_every_registry_entry() { + assert!(adapter_registry_linkage_is_valid()); + + for operation in OPERATION_REGISTRY { + let parsed = TargetCliArgs::try_parse_from(operation.cli_path.split_whitespace()) + .unwrap_or_else(|error| { + panic!("{} failed to parse: {error}", operation.cli_path); + }); + let request = MvpOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + + assert_eq!(request.operation_id(), operation.operation_id); + assert_eq!(request.spec().mcp_tool, operation.mcp_tool); + assert_eq!(request.request_type_name(), operation.rust_request); + assert_eq!( + MvpOperationRequest::request_type_for_operation(operation.operation_id), + Some(operation.rust_request) + ); + } + } + + #[test] + fn adapter_context_carries_target_global_scope() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "--format", + "json", + "--account-id", + "acct_test", + "--farm-id", + "farm_test", + "--profile", + "repo_local", + "--signer-session-id", + "sess_test", + "--relay", + "wss://relay.one", + "--online", + "--dry-run", + "--idempotency-key", + "idem_test", + "--correlation-id", + "corr_test", + "--approval-token", + "approval_test", + "--no-input", + "--quiet", + "--verbose", + "--trace", + "--no-color", + "workspace", + "get", + ]) + .expect("target args parse"); + + let request = MvpOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let context = request.context(); + + assert_eq!(context.output_format, OperationOutputFormat::Json); + assert_eq!(context.account_id.as_deref(), Some("acct_test")); + assert_eq!(context.farm_id.as_deref(), Some("farm_test")); + assert_eq!(context.profile.as_deref(), Some("repo_local")); + assert_eq!(context.signer_session_id.as_deref(), Some("sess_test")); + assert_eq!(context.relays, vec!["wss://relay.one".to_owned()]); + assert_eq!(context.network_mode, OperationNetworkMode::Online); + assert!(context.dry_run); + assert_eq!(context.idempotency_key.as_deref(), Some("idem_test")); + assert_eq!(context.correlation_id.as_deref(), Some("corr_test")); + assert_eq!(context.approval_token.as_deref(), Some("approval_test")); + assert_eq!(context.input_mode, OperationInputMode::NoInput); + assert!(context.quiet); + assert!(context.verbose); + assert!(context.trace); + assert!(!context.color); + } + + #[test] + fn typed_service_boundary_returns_enveloped_result() { + struct WorkspaceService; + + impl OperationService<WorkspaceGetRequest> for WorkspaceService { + type Result = WorkspaceGetResult; + + fn execute( + &self, + request: OperationRequest<WorkspaceGetRequest>, + ) -> Result<OperationResult<Self::Result>, super::OperationAdapterError> { + assert_eq!(request.operation_id(), "workspace.get"); + OperationResult::new(WorkspaceGetResult::default()) + } + } + + let adapter = OperationAdapter::new(WorkspaceService); + let context = OperationContext::default(); + let request = OperationRequest::new(context.clone(), WorkspaceGetRequest::default()) + .expect("typed request"); + let result = adapter.execute(request).expect("typed result"); + let envelope = result + .to_envelope(context.envelope_context("req_test")) + .expect("operation envelope"); + + assert_eq!(envelope.operation_id, "workspace.get"); + assert_eq!(envelope.kind, "workspace.get"); + assert_eq!(envelope.request_id, "req_test"); + assert_eq!(envelope.result, json!({})); + } +}