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:
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(),
+ }
+ }
+}