cli

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

commit a77ce55010d17778a5930964af0a815b3825962f
parent 0603b9806d5ff29b833e4bdd1704366abc6e4892
Author: triesap <tyson@radroots.org>
Date:   Sun, 26 Apr 2026 23:08:28 +0000

cli: back core operations with adapters

- add the core operation service for workspace, health, config, account, and store
- carry typed operation payload maps through the registry-bound request and result types
- envelope core service results without depending on command view dispatch
- cover workspace, store, and account service paths with focused tests

Diffstat:
Msrc/main.rs | 1+
Msrc/operation_adapter.rs | 100++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Asrc/operation_core.rs | 751+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 844 insertions(+), 8 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -4,6 +4,7 @@ mod cli; mod commands; mod domain; mod operation_adapter; +mod operation_core; mod operation_registry; mod output_contract; mod render; diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -3,6 +3,7 @@ use std::fmt::Debug; use serde::Serialize; +use serde_json::{Map, Value}; use crate::operation_registry::{OPERATION_REGISTRY, OperationSpec, get_operation}; use crate::output_contract::{ @@ -118,17 +119,37 @@ impl OperationContext { } } -pub trait OperationRequestPayload: Debug + Clone + PartialEq + Eq + 'static { +pub type OperationData = Map<String, Value>; + +pub trait OperationRequestPayload: Debug + Clone + PartialEq + 'static { const OPERATION_ID: &'static str; const REQUEST_TYPE: &'static str; } -pub trait OperationResultPayload: Debug + Clone + PartialEq + Eq + Serialize + 'static { +pub trait OperationRequestData: OperationRequestPayload { + fn input(&self) -> &OperationData; +} + +pub trait OperationResultPayload: Debug + Clone + PartialEq + Serialize + 'static { const OPERATION_ID: &'static str; const RESULT_TYPE: &'static str; } -#[derive(Debug, Clone, PartialEq, Eq)] +pub trait OperationResultData: OperationResultPayload + Sized { + fn from_data(data: OperationData) -> Self; + + fn from_value(value: Value) -> Self { + Self::from_data(value_to_data(value)) + } + + fn from_serializable<T: Serialize>(value: &T) -> Result<Self, OperationAdapterError> { + Ok(Self::from_value(serde_json::to_value(value).map_err( + |error| OperationAdapterError::Serialization(error.to_string()), + )?)) + } +} + +#[derive(Debug, Clone, PartialEq)] pub struct OperationRequest<P: OperationRequestPayload> { pub spec: &'static OperationSpec, pub context: OperationContext, @@ -272,11 +293,18 @@ pub enum OperationAdapterError { }, #[error("failed to serialize operation result: {0}")] Serialization(String), + #[error("invalid operation input for `{operation_id}`: {message}")] + InvalidInput { + operation_id: String, + message: String, + }, + #[error("operation runtime error: {0}")] + Runtime(String), } macro_rules! mvp_operation_contracts { ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { - #[derive(Debug, Clone, PartialEq, Eq)] + #[derive(Debug, Clone, PartialEq)] pub enum MvpOperationRequest { $( $variant(OperationRequest<$request>), )+ } @@ -355,25 +383,81 @@ macro_rules! mvp_operation_contracts { } $( - #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] - pub struct $request {} + #[derive(Debug, Default, Clone, PartialEq, Serialize)] + pub struct $request { + #[serde(flatten)] + pub input: OperationData, + } + + impl $request { + pub fn from_data(input: OperationData) -> Self { + Self { input } + } + } 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 OperationRequestData for $request { + fn input(&self) -> &OperationData { + &self.input + } + } + + #[derive(Debug, Default, Clone, PartialEq, Serialize)] + pub struct $result { + #[serde(flatten)] + pub data: OperationData, + } + + impl $result { + pub fn from_data(data: OperationData) -> Self { + Self { data } + } + + pub fn from_value(value: Value) -> Self { + Self { + data: value_to_data(value), + } + } + + pub fn from_serializable<T: Serialize>( + value: &T, + ) -> Result<Self, OperationAdapterError> { + Ok(Self::from_value( + serde_json::to_value(value) + .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?, + )) + } + } impl OperationResultPayload for $result { const OPERATION_ID: &'static str = $operation_id; const RESULT_TYPE: &'static str = stringify!($result); } + + impl OperationResultData for $result { + fn from_data(data: OperationData) -> Self { + Self { data } + } + } )+ }; } +fn value_to_data(value: Value) -> OperationData { + match value { + Value::Object(map) => map, + other => { + let mut map = OperationData::new(); + map.insert("value".to_owned(), other); + map + } + } +} + mvp_operation_contracts! { WorkspaceInit => (WorkspaceInitRequest, WorkspaceInitResult, "workspace.init"), WorkspaceGet => (WorkspaceGetRequest, WorkspaceGetResult, "workspace.get"), diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -0,0 +1,751 @@ +#![allow(dead_code)] + +use std::path::PathBuf; + +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::cli::LocalExportFormatArg; +use crate::operation_adapter::{ + AccountCreateRequest, AccountCreateResult, AccountGetRequest, AccountGetResult, + AccountImportRequest, AccountImportResult, AccountListRequest, AccountListResult, + AccountRemoveRequest, AccountRemoveResult, AccountSelectionClearRequest, + AccountSelectionClearResult, AccountSelectionGetRequest, AccountSelectionGetResult, + AccountSelectionUpdateRequest, AccountSelectionUpdateResult, ConfigGetRequest, ConfigGetResult, + HealthCheckRunRequest, HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, + OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, + OperationResult, OperationResultData, OperationService, StoreBackupCreateRequest, + StoreBackupCreateResult, StoreExportRequest, StoreExportResult, StoreInitRequest, + StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, + WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult, +}; +use crate::runtime::RuntimeError; +use crate::runtime::accounts::{ + account_resolution_view, account_summary_view, clear_default_account, + create_or_migrate_default_account, import_public_identity, remove_account, + resolve_account_resolution, select_account, snapshot, unresolved_account_reason, +}; +use crate::runtime::config::RuntimeConfig; +use crate::runtime::logging::LoggingState; + +pub struct CoreOperationService<'a> { + config: &'a RuntimeConfig, + logging: &'a LoggingState, +} + +impl<'a> CoreOperationService<'a> { + pub fn new(config: &'a RuntimeConfig, logging: &'a LoggingState) -> Self { + Self { config, logging } + } +} + +impl OperationService<WorkspaceInitRequest> for CoreOperationService<'_> { + type Result = WorkspaceInitResult; + + fn execute( + &self, + request: OperationRequest<WorkspaceInitRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.dry_run { + return json_operation_result::<WorkspaceInitResult>(json!({ + "state": "dry_run", + "profile": self.config.paths.profile, + "local_root": self.config.local.root.display().to_string(), + "replica_db_path": self.config.local.replica_db_path.display().to_string(), + })); + } + + let local = map_runtime(crate::runtime::local::init(self.config))?; + json_operation_result::<WorkspaceInitResult>(json!({ + "state": local.state, + "profile": self.config.paths.profile, + "local": local, + })) + } +} + +impl OperationService<WorkspaceGetRequest> for CoreOperationService<'_> { + type Result = WorkspaceGetResult; + + fn execute( + &self, + _request: OperationRequest<WorkspaceGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + json_operation_result::<WorkspaceGetResult>(json!({ + "profile": self.config.paths.profile, + "profile_source": self.config.paths.profile_source, + "root_source": self.config.paths.root_source, + "app_namespace": self.config.paths.app_namespace, + "workspace_config_path": self.config.paths.workspace_config_path.as_ref().map(|path| path.display().to_string()), + "app_config_path": self.config.paths.app_config_path.display().to_string(), + "app_data_root": self.config.paths.app_data_root.display().to_string(), + "app_logs_root": self.config.paths.app_logs_root.display().to_string(), + "local_root": self.config.local.root.display().to_string(), + "replica_db_path": self.config.local.replica_db_path.display().to_string(), + })) + } +} + +impl OperationService<HealthStatusGetRequest> for CoreOperationService<'_> { + type Result = HealthStatusGetResult; + + fn execute( + &self, + _request: OperationRequest<HealthStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let store = map_runtime(crate::runtime::local::status(self.config))?; + let account = map_runtime(resolve_account_resolution(self.config))?; + json_operation_result::<HealthStatusGetResult>(json!({ + "state": if store.state == "ready" { "ready" } else { "needs_attention" }, + "store": store, + "account_resolution": account_resolution_view(&account), + "logging": { + "initialized": self.logging.initialized, + "current_file": self.logging.current_file.as_ref().map(|path| path.display().to_string()), + }, + })) + } +} + +impl OperationService<HealthCheckRunRequest> for CoreOperationService<'_> { + type Result = HealthCheckRunResult; + + fn execute( + &self, + _request: OperationRequest<HealthCheckRunRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let store = map_runtime(crate::runtime::local::status(self.config))?; + let account = map_runtime(resolve_account_resolution(self.config))?; + let account_reason = if account.resolved_account.is_some() { + None + } else { + Some(map_runtime(unresolved_account_reason(self.config))?) + }; + json_operation_result::<HealthCheckRunResult>(json!({ + "state": if store.state == "ready" && account.resolved_account.is_some() { "ready" } else { "needs_attention" }, + "checks": { + "workspace": { + "state": "ready", + "profile": self.config.paths.profile, + }, + "store": { + "state": store.state, + "reason": store.reason, + }, + "account": { + "state": if account.resolved_account.is_some() { "ready" } else { "unconfigured" }, + "reason": account_reason, + }, + }, + })) + } +} + +impl OperationService<ConfigGetRequest> for CoreOperationService<'_> { + type Result = ConfigGetResult; + + fn execute( + &self, + _request: OperationRequest<ConfigGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + json_operation_result::<ConfigGetResult>(json!({ + "output": { + "format": self.config.output.format.as_str(), + "verbosity": self.config.output.verbosity.as_str(), + "color": self.config.output.color, + "dry_run": self.config.output.dry_run, + }, + "interaction": { + "input_enabled": self.config.interaction.input_enabled, + "prompts_allowed": self.config.interaction.prompts_allowed, + "confirmations_allowed": self.config.interaction.confirmations_allowed, + }, + "paths": { + "profile": self.config.paths.profile, + "app_config_path": self.config.paths.app_config_path.display().to_string(), + "workspace_config_path": self.config.paths.workspace_config_path.as_ref().map(|path| path.display().to_string()), + "app_data_root": self.config.paths.app_data_root.display().to_string(), + "app_logs_root": self.config.paths.app_logs_root.display().to_string(), + }, + "account": { + "selector": self.config.account.selector, + "store_path": self.config.account.store_path.display().to_string(), + "secrets_dir": self.config.account.secrets_dir.display().to_string(), + }, + "relay": { + "count": self.config.relay.urls.len(), + "urls": self.config.relay.urls, + "source": self.config.relay.source.as_str(), + }, + "local": { + "root": self.config.local.root.display().to_string(), + "replica_db_path": self.config.local.replica_db_path.display().to_string(), + "backups_dir": self.config.local.backups_dir.display().to_string(), + "exports_dir": self.config.local.exports_dir.display().to_string(), + }, + })) + } +} + +impl OperationService<AccountCreateRequest> for CoreOperationService<'_> { + type Result = AccountCreateResult; + + fn execute( + &self, + request: OperationRequest<AccountCreateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.dry_run { + return json_operation_result::<AccountCreateResult>(json!({ + "state": "dry_run", + "store_path": self.config.account.store_path.display().to_string(), + })); + } + + let result = map_runtime(create_or_migrate_default_account(self.config))?; + json_operation_result::<AccountCreateResult>(json!({ + "state": match result.mode { + crate::runtime::accounts::AccountCreateMode::Created => "created", + crate::runtime::accounts::AccountCreateMode::Migrated => "migrated", + }, + "account": account_summary_view(&result.account), + })) + } +} + +impl OperationService<AccountImportRequest> for CoreOperationService<'_> { + type Result = AccountImportResult; + + fn execute( + &self, + request: OperationRequest<AccountImportRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let path = required_path(&request, "path")?; + let make_default = bool_input(&request, "default").unwrap_or(false); + if request.context.dry_run { + return json_operation_result::<AccountImportResult>(json!({ + "state": "dry_run", + "path": path.display().to_string(), + "default": make_default, + })); + } + + let account = map_runtime(import_public_identity( + self.config, + path.as_path(), + make_default, + ))?; + json_operation_result::<AccountImportResult>(json!({ + "state": "imported", + "account": account_summary_view(&account), + })) + } +} + +impl OperationService<AccountGetRequest> for CoreOperationService<'_> { + type Result = AccountGetResult; + + fn execute( + &self, + request: OperationRequest<AccountGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let scoped; + let config = if let Some(selector) = string_input(&request, "selector") { + scoped = selected_config(self.config, selector); + &scoped + } else { + self.config + }; + let resolution = map_runtime(resolve_account_resolution(config))?; + let reason = if resolution.resolved_account.is_some() { + None + } else { + Some(map_runtime(unresolved_account_reason(config))?) + }; + json_operation_result::<AccountGetResult>(json!({ + "state": if resolution.resolved_account.is_some() { "ready" } else { "unconfigured" }, + "reason": reason, + "account_resolution": account_resolution_view(&resolution), + })) + } +} + +impl OperationService<AccountListRequest> for CoreOperationService<'_> { + type Result = AccountListResult; + + fn execute( + &self, + _request: OperationRequest<AccountListRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let snapshot = map_runtime(snapshot(self.config))?; + let accounts = snapshot + .accounts + .iter() + .map(account_summary_view) + .collect::<Vec<_>>(); + json_operation_result::<AccountListResult>(json!({ + "source": crate::runtime::accounts::SHARED_ACCOUNT_STORE_SOURCE, + "count": accounts.len(), + "accounts": accounts, + })) + } +} + +impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { + type Result = AccountRemoveResult; + + fn execute( + &self, + request: OperationRequest<AccountRemoveRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let selector = required_string(&request, "selector")?; + if request.context.dry_run { + return json_operation_result::<AccountRemoveResult>(json!({ + "state": "dry_run", + "selector": selector, + })); + } + + let result = map_runtime(remove_account(self.config, selector.as_str()))?; + json_operation_result::<AccountRemoveResult>(json!({ + "state": "removed", + "removed_account": account_summary_view(&result.removed_account), + "default_cleared": result.default_cleared, + "remaining_account_count": result.remaining_account_count, + })) + } +} + +impl OperationService<AccountSelectionGetRequest> for CoreOperationService<'_> { + type Result = AccountSelectionGetResult; + + fn execute( + &self, + _request: OperationRequest<AccountSelectionGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let resolution = map_runtime(resolve_account_resolution(self.config))?; + json_operation_result::<AccountSelectionGetResult>(json!({ + "account_resolution": account_resolution_view(&resolution), + })) + } +} + +impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_> { + type Result = AccountSelectionUpdateResult; + + fn execute( + &self, + request: OperationRequest<AccountSelectionUpdateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let selector = required_string(&request, "selector")?; + if request.context.dry_run { + return json_operation_result::<AccountSelectionUpdateResult>(json!({ + "state": "dry_run", + "selector": selector, + })); + } + + let account = map_runtime(select_account(self.config, selector.as_str()))?; + json_operation_result::<AccountSelectionUpdateResult>(json!({ + "state": "default", + "account": account_summary_view(&account), + })) + } +} + +impl OperationService<AccountSelectionClearRequest> for CoreOperationService<'_> { + type Result = AccountSelectionClearResult; + + fn execute( + &self, + request: OperationRequest<AccountSelectionClearRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.dry_run { + return json_operation_result::<AccountSelectionClearResult>(json!({ + "state": "dry_run", + })); + } + + let result = map_runtime(clear_default_account(self.config))?; + json_operation_result::<AccountSelectionClearResult>(json!({ + "state": if result.cleared_account.is_some() { "cleared" } else { "already_clear" }, + "cleared_account": result.cleared_account.as_ref().map(account_summary_view), + "remaining_account_count": result.remaining_account_count, + })) + } +} + +impl OperationService<StoreInitRequest> for CoreOperationService<'_> { + type Result = StoreInitResult; + + fn execute( + &self, + request: OperationRequest<StoreInitRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + if request.context.dry_run { + return json_operation_result::<StoreInitResult>(json!({ + "state": "dry_run", + "path": self.config.local.replica_db_path.display().to_string(), + })); + } + + let view = map_runtime(crate::runtime::local::init(self.config))?; + serialized_operation_result::<StoreInitResult, _>(&view) + } +} + +impl OperationService<StoreStatusGetRequest> for CoreOperationService<'_> { + type Result = StoreStatusGetResult; + + fn execute( + &self, + _request: OperationRequest<StoreStatusGetRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let view = map_runtime(crate::runtime::local::status(self.config))?; + serialized_operation_result::<StoreStatusGetResult, _>(&view) + } +} + +impl OperationService<StoreExportRequest> for CoreOperationService<'_> { + type Result = StoreExportResult; + + fn execute( + &self, + request: OperationRequest<StoreExportRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let output = optional_path(&request, "output") + .unwrap_or_else(|| self.config.local.exports_dir.join("store-export.json")); + let format = match string_input(&request, "format").as_deref() { + Some("ndjson") => LocalExportFormatArg::Ndjson, + Some("json") | None => LocalExportFormatArg::Json, + Some(other) => { + return Err(invalid_input( + request.operation_id(), + format!("format must be `json` or `ndjson`, got `{other}`"), + )); + } + }; + if request.context.dry_run { + return json_operation_result::<StoreExportResult>(json!({ + "state": "dry_run", + "format": format.as_str(), + "file": output.display().to_string(), + })); + } + + let view = map_runtime(crate::runtime::local::export( + self.config, + format, + output.as_path(), + ))?; + serialized_operation_result::<StoreExportResult, _>(&view) + } +} + +impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> { + type Result = StoreBackupCreateResult; + + fn execute( + &self, + request: OperationRequest<StoreBackupCreateRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let output = optional_path(&request, "output") + .unwrap_or_else(|| self.config.local.backups_dir.join("store-backup.json")); + if request.context.dry_run { + return json_operation_result::<StoreBackupCreateResult>(json!({ + "state": "dry_run", + "file": output.display().to_string(), + })); + } + + let view = map_runtime(crate::runtime::local::backup(self.config, output.as_path()))?; + serialized_operation_result::<StoreBackupCreateResult, _>(&view) + } +} + +fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, + T: Serialize, +{ + OperationResult::new(R::from_serializable(value)?) +} + +fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + OperationResult::new(R::from_value(value)) +} + +fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { + result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) +} + +fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig { + let mut config = config.clone(); + config.account.selector = Some(selector); + config +} + +fn required_string<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<String, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key).ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("missing required `{key}` input"), + ) + }) +} + +fn required_path<P>( + request: &OperationRequest<P>, + key: &str, +) -> Result<PathBuf, OperationAdapterError> +where + P: OperationRequestPayload + OperationRequestData, +{ + optional_path(request, key).ok_or_else(|| { + invalid_input( + request.operation_id(), + format!("missing required `{key}` input"), + ) + }) +} + +fn optional_path<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf> +where + P: OperationRequestPayload + OperationRequestData, +{ + string_input(request, key).map(PathBuf::from) +} + +fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> +where + P: OperationRequestPayload + OperationRequestData, +{ + request + .payload + .input() + .get(key) + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> +where + P: OperationRequestPayload + OperationRequestData, +{ + request.payload.input().get(key).and_then(Value::as_bool) +} + +fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { + OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use radroots_runtime_paths::RadrootsMigrationReport; + use radroots_secret_vault::RadrootsSecretBackend; + use tempfile::tempdir; + + use super::CoreOperationService; + use crate::operation_adapter::{ + AccountCreateRequest, AccountListRequest, OperationAdapter, OperationContext, + OperationRequest, StoreStatusGetRequest, WorkspaceGetRequest, + }; + use crate::runtime::config::{ + AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, + LocalConfig, LoggingConfig, MigrationConfig, MycConfig, OutputConfig, OutputFormat, + PathsConfig, RelayConfig, RelayConfigSource, RelayPublishPolicy, RpcConfig, RuntimeConfig, + SignerBackend, SignerConfig, Verbosity, + }; + use crate::runtime::logging::LoggingState; + + #[test] + fn core_service_envelopes_workspace_get() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let logging = LoggingState { + initialized: true, + current_file: None, + }; + let service = OperationAdapter::new(CoreOperationService::new(&config, &logging)); + let request = + OperationRequest::new(OperationContext::default(), WorkspaceGetRequest::default()) + .expect("workspace request"); + let result = service.execute(request).expect("workspace result"); + let envelope = result + .to_envelope(OperationContext::default().envelope_context("req_workspace")) + .expect("workspace envelope"); + + assert_eq!(envelope.operation_id, "workspace.get"); + assert_eq!(envelope.kind, "workspace.get"); + assert_eq!(envelope.request_id, "req_workspace"); + assert_eq!(envelope.result["profile"], "interactive_user"); + assert_eq!( + envelope.result["replica_db_path"], + config.local.replica_db_path.display().to_string() + ); + } + + #[test] + fn core_service_backs_store_status() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let logging = LoggingState { + initialized: false, + current_file: None, + }; + let service = OperationAdapter::new(CoreOperationService::new(&config, &logging)); + let request = OperationRequest::new( + OperationContext::default(), + StoreStatusGetRequest::default(), + ) + .expect("store status request"); + let result = service.execute(request).expect("store status result"); + let envelope = result + .to_envelope(OperationContext::default().envelope_context("req_store")) + .expect("store status envelope"); + + assert_eq!(envelope.operation_id, "store.status.get"); + assert_eq!(envelope.result["state"], "unconfigured"); + assert_eq!(envelope.result["replica_db"], "missing"); + } + + #[test] + fn core_service_backs_account_create_and_list() { + let dir = tempdir().expect("tempdir"); + let config = sample_config(dir.path()); + let logging = LoggingState { + initialized: false, + current_file: None, + }; + let service = OperationAdapter::new(CoreOperationService::new(&config, &logging)); + let create = + OperationRequest::new(OperationContext::default(), AccountCreateRequest::default()) + .expect("account create request"); + let create_result = service.execute(create).expect("account create result"); + let create_envelope = create_result + .to_envelope(OperationContext::default().envelope_context("req_create")) + .expect("account create envelope"); + + assert_eq!(create_envelope.operation_id, "account.create"); + assert_eq!(create_envelope.result["state"], "created"); + assert!(create_envelope.result["account"]["id"].is_string()); + + let list = + OperationRequest::new(OperationContext::default(), AccountListRequest::default()) + .expect("account list request"); + let list_result = service.execute(list).expect("account list result"); + let list_envelope = list_result + .to_envelope(OperationContext::default().envelope_context("req_list")) + .expect("account list envelope"); + + assert_eq!(list_envelope.operation_id, "account.list"); + assert_eq!(list_envelope.result["count"], 1); + assert_eq!(list_envelope.result["accounts"][0]["is_default"], true); + } + + fn sample_config(root: &Path) -> RuntimeConfig { + let data = root.join("data"); + let logs = root.join("logs"); + let secrets = root.join("secrets"); + RuntimeConfig { + output: OutputConfig { + format: OutputFormat::Human, + verbosity: Verbosity::Normal, + color: true, + dry_run: false, + }, + interaction: InteractionConfig { + input_enabled: true, + assume_yes: false, + stdin_tty: false, + stdout_tty: false, + prompts_allowed: false, + confirmations_allowed: false, + }, + paths: PathsConfig { + profile: "interactive_user".into(), + profile_source: "test".into(), + allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], + root_source: "test".into(), + repo_local_root: None, + repo_local_root_source: None, + subordinate_path_override_source: "runtime_config".into(), + app_namespace: "apps/cli".into(), + shared_accounts_namespace: "shared/accounts".into(), + shared_identities_namespace: "shared/identities".into(), + app_config_path: root.join("config/apps/cli/config.toml"), + workspace_config_path: None, + app_data_root: data.join("apps/cli"), + app_logs_root: logs.join("apps/cli"), + shared_accounts_data_root: data.join("shared/accounts"), + shared_accounts_secrets_root: secrets.join("shared/accounts"), + default_identity_path: secrets.join("shared/identities/default.json"), + }, + migration: MigrationConfig { + report: RadrootsMigrationReport::empty(), + }, + logging: LoggingConfig { + filter: "info".into(), + directory: None, + stdout: false, + }, + account: AccountConfig { + selector: None, + store_path: data.join("shared/accounts/store.json"), + secrets_dir: secrets.join("shared/accounts"), + secret_backend: RadrootsSecretBackend::EncryptedFile, + secret_fallback: None, + }, + account_secret_contract: AccountSecretContractConfig { + default_backend: "host_vault".into(), + default_fallback: Some("encrypted_file".into()), + allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], + host_vault_policy: Some("desktop".into()), + uses_protected_store: true, + }, + identity: IdentityConfig { + path: secrets.join("shared/identities/default.json"), + }, + signer: SignerConfig { + backend: SignerBackend::Local, + }, + relay: RelayConfig { + urls: Vec::new(), + publish_policy: RelayPublishPolicy::Any, + source: RelayConfigSource::Defaults, + }, + local: LocalConfig { + root: data.join("apps/cli/replica"), + replica_db_path: data.join("apps/cli/replica/replica.sqlite"), + backups_dir: data.join("apps/cli/replica/backups"), + exports_dir: data.join("apps/cli/replica/exports"), + }, + myc: MycConfig { + executable: PathBuf::from("myc"), + status_timeout_ms: 2_000, + }, + hyf: HyfConfig { + enabled: false, + executable: PathBuf::from("hyfd"), + }, + rpc: RpcConfig { + url: "http://127.0.0.1:7070".into(), + bridge_bearer_token: None, + }, + capability_bindings: Vec::new(), + } + } +}