commit d49ba4f3d7bf43f4a33d57d6393b520d6f410e2a
parent f2b8a487403e2a020e205400fb637d449a7ea1fe
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 09:11:59 +0000
cli: preflight local mutation dry-runs
- validate account import remove and selection dry-runs without mutation
- preflight store backup dry-runs against initialized local state
- keep store export dry-run explicitly unsupported
- add process coverage for account and store dry-run contracts
Diffstat:
6 files changed, 364 insertions(+), 38 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -733,6 +733,7 @@ fn looks_like_validation_failure(value: &str) -> bool {
"must not",
"must be",
"validation",
+ "failed to import account",
],
)
}
diff --git a/src/operation_core.rs b/src/operation_core.rs
@@ -3,6 +3,7 @@ use std::path::PathBuf;
use serde::Serialize;
use serde_json::{Value, json};
+use crate::domain::runtime::{CommandDisposition, LocalBackupView};
use crate::operation_adapter::{
AccountCreateRequest, AccountCreateResult, AccountGetRequest, AccountGetResult,
AccountImportRequest, AccountImportResult, AccountListRequest, AccountListResult,
@@ -19,8 +20,9 @@ use crate::operation_adapter::{
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,
+ create_or_migrate_default_account, import_public_identity, preview_account_removal,
+ preview_public_identity_import, remove_account, resolve_account_resolution,
+ resolve_account_selector, select_account, snapshot, unresolved_account_reason,
};
use crate::runtime::config::RuntimeConfig;
use crate::runtime::logging::LoggingState;
@@ -220,10 +222,15 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> {
let path = required_path(&request, "path")?;
let make_default = bool_input(&request, "default").unwrap_or(false);
if request.context.dry_run {
+ let account = map_expected_runtime(
+ request.operation_id(),
+ preview_public_identity_import(self.config, path.as_path(), make_default),
+ )?;
return json_operation_result::<AccountImportResult>(json!({
"state": "dry_run",
"path": path.display().to_string(),
"default": make_default,
+ "account": account_summary_view(&account),
}));
}
if request.context.approval_token.is_none() {
@@ -232,11 +239,10 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> {
));
}
- let account = map_runtime(import_public_identity(
- self.config,
- path.as_path(),
- make_default,
- ))?;
+ let account = map_expected_runtime(
+ request.operation_id(),
+ import_public_identity(self.config, path.as_path(), make_default),
+ )?;
json_operation_result::<AccountImportResult>(json!({
"state": "imported",
"account": account_summary_view(&account),
@@ -304,9 +310,15 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> {
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
let selector = required_string(&request, "selector")?;
if request.context.dry_run {
+ let preview =
+ preview_account_removal(self.config, selector.as_str()).map_err(|error| {
+ OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
+ })?;
return json_operation_result::<AccountRemoveResult>(json!({
"state": "dry_run",
- "selector": selector,
+ "removed_account": account_summary_view(&preview.account),
+ "default_would_clear": preview.default_would_clear,
+ "remaining_account_count": preview.remaining_account_count,
}));
}
if request.context.approval_token.is_none() {
@@ -350,9 +362,13 @@ impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
let selector = required_string(&request, "selector")?;
if request.context.dry_run {
+ let account =
+ resolve_account_selector(self.config, selector.as_str()).map_err(|error| {
+ OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
+ })?;
return json_operation_result::<AccountSelectionUpdateResult>(json!({
"state": "dry_run",
- "selector": selector,
+ "account": account_summary_view(&account),
}));
}
@@ -439,11 +455,10 @@ impl OperationService<StoreExportRequest> for CoreOperationService<'_> {
}
};
if request.context.dry_run {
- return json_operation_result::<StoreExportResult>(json!({
- "state": "dry_run",
- "format": format.as_str(),
- "file": output.display().to_string(),
- }));
+ return Err(invalid_input(
+ request.operation_id(),
+ "`radroots store export` does not support --dry-run".to_owned(),
+ ));
}
let view = map_runtime(crate::runtime::local::export(
@@ -465,14 +480,18 @@ impl OperationService<StoreBackupCreateRequest> for CoreOperationService<'_> {
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_expected_runtime(
+ request.operation_id(),
+ crate::runtime::local::backup_preflight(self.config, output.as_path()),
+ )?;
+ return local_backup_result(request.operation_id(), &view);
}
- let view = map_runtime(crate::runtime::local::backup(self.config, output.as_path()))?;
- serialized_operation_result::<StoreBackupCreateResult, _>(&view)
+ let view = map_expected_runtime(
+ request.operation_id(),
+ crate::runtime::local::backup(self.config, output.as_path()),
+ )?;
+ local_backup_result(request.operation_id(), &view)
}
}
@@ -495,6 +514,47 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter
result.map_err(|error| OperationAdapterError::Runtime(error.to_string()))
}
+fn map_expected_runtime<T>(
+ operation_id: &str,
+ result: Result<T, RuntimeError>,
+) -> Result<T, OperationAdapterError> {
+ result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error))
+}
+
+fn local_backup_result(
+ operation_id: &str,
+ view: &LocalBackupView,
+) -> Result<OperationResult<StoreBackupCreateResult>, OperationAdapterError> {
+ match view.disposition() {
+ CommandDisposition::Success => {
+ serialized_operation_result::<StoreBackupCreateResult, _>(view)
+ }
+ CommandDisposition::Unconfigured => Err(OperationAdapterError::unconfigured(
+ operation_id,
+ view.reason
+ .clone()
+ .unwrap_or_else(|| "store backup is unconfigured".to_owned()),
+ )),
+ CommandDisposition::ExternalUnavailable => Err(OperationAdapterError::unavailable(
+ operation_id,
+ view.reason
+ .clone()
+ .unwrap_or_else(|| "store backup is unavailable".to_owned()),
+ )),
+ CommandDisposition::Unsupported => Err(invalid_input(
+ operation_id,
+ view.reason
+ .clone()
+ .unwrap_or_else(|| "store backup is unsupported".to_owned()),
+ )),
+ CommandDisposition::InternalError => Err(OperationAdapterError::Runtime(
+ view.reason
+ .clone()
+ .unwrap_or_else(|| "store backup failed".to_owned()),
+ )),
+ }
+}
+
fn selected_config(config: &RuntimeConfig, selector: String) -> RuntimeConfig {
let mut config = config.clone();
config.account.selector = Some(selector);
diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs
@@ -1,6 +1,8 @@
use std::path::Path;
-use radroots_identity::{IdentityError, RadrootsIdentity, load_identity_profile};
+use radroots_identity::{
+ IdentityError, RadrootsIdentity, RadrootsIdentityPublic, load_identity_profile,
+};
use radroots_nostr_accounts::prelude::{
RadrootsNostrAccountRecord, RadrootsNostrAccountStatus, RadrootsNostrAccountsError,
RadrootsNostrAccountsManager,
@@ -67,6 +69,13 @@ pub struct AccountRemoveResult {
pub remaining_account_count: usize,
}
+#[derive(Debug, Clone)]
+pub struct AccountRemovePreview {
+ pub account: AccountRecordView,
+ pub default_would_clear: bool,
+ pub remaining_account_count: usize,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AccountResolutionSource {
InvocationOverride,
@@ -130,13 +139,7 @@ pub fn import_public_identity(
make_default: bool,
) -> Result<AccountRecordView, RuntimeError> {
let manager = account_manager(config)?;
- let public_identity = load_identity_profile(path).map_err(|error| {
- RuntimeError::Config(format!(
- "failed to import account from {}: {}",
- path.display(),
- format_identity_error(error)
- ))
- })?;
+ let public_identity = load_public_identity_for_import(path)?;
let imported_account_id =
manager.upsert_public_identity(public_identity, None, make_default)?;
let snapshot = snapshot_from_manager(&manager)?;
@@ -147,6 +150,34 @@ pub fn import_public_identity(
)
}
+pub fn preview_public_identity_import(
+ config: &RuntimeConfig,
+ path: &Path,
+ make_default: bool,
+) -> Result<AccountRecordView, RuntimeError> {
+ let public_identity = load_public_identity_for_import(path)?;
+ let manager = account_manager(config)?;
+ let snapshot = snapshot_from_manager(&manager)?;
+ if let Some(existing) = snapshot
+ .accounts
+ .iter()
+ .find(|account| account.record.account_id == public_identity.id)
+ .cloned()
+ {
+ let mut account = existing;
+ if make_default {
+ account.is_default = true;
+ }
+ return Ok(account);
+ }
+
+ Ok(AccountRecordView {
+ record: RadrootsNostrAccountRecord::new(public_identity, None, 0),
+ is_default: make_default,
+ signer: "watch_only",
+ })
+}
+
pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> {
let manager = account_manager(config)?;
snapshot_from_manager(&manager)
@@ -209,6 +240,15 @@ pub fn select_account(
})
}
+pub fn resolve_account_selector(
+ config: &RuntimeConfig,
+ selector: &str,
+) -> Result<AccountRecordView, RuntimeError> {
+ let manager = account_manager(config)?;
+ let snapshot = snapshot_from_manager(&manager)?;
+ resolve_selector_account(&manager, &snapshot, selector)
+}
+
pub fn clear_default_account(
config: &RuntimeConfig,
) -> Result<AccountClearDefaultResult, RuntimeError> {
@@ -244,6 +284,20 @@ pub fn remove_account(
})
}
+pub fn preview_account_removal(
+ config: &RuntimeConfig,
+ selector: &str,
+) -> Result<AccountRemovePreview, RuntimeError> {
+ let manager = account_manager(config)?;
+ let snapshot = snapshot_from_manager(&manager)?;
+ let account = resolve_selector_account(&manager, &snapshot, selector)?;
+ Ok(AccountRemovePreview {
+ default_would_clear: account.is_default,
+ remaining_account_count: snapshot.accounts.len().saturating_sub(1),
+ account,
+ })
+}
+
pub fn resolved_account_signing_status(
config: &RuntimeConfig,
) -> Result<RadrootsNostrAccountStatus, RuntimeError> {
@@ -453,6 +507,16 @@ fn format_identity_error(error: IdentityError) -> String {
}
}
+fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic, RuntimeError> {
+ load_identity_profile(path).map_err(|error| {
+ RuntimeError::Config(format!(
+ "failed to import account from {}: {}",
+ path.display(),
+ format_identity_error(error)
+ ))
+ })
+}
+
fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> {
let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed(
config.account.store_path.as_path(),
diff --git a/src/runtime/local.rs b/src/runtime/local.rs
@@ -92,16 +92,7 @@ pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, RuntimeError> {
pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView, RuntimeError> {
if !config.local.replica_db_path.exists() {
- return Ok(LocalBackupView {
- state: "unconfigured".to_owned(),
- source: LOCAL_SOURCE.to_owned(),
- file: output.display().to_string(),
- size_bytes: 0,
- backup_format_version: String::new(),
- replica_db_version: String::new(),
- reason: Some("local replica database is not initialized".to_owned()),
- actions: vec!["radroots store init".to_owned()],
- });
+ return Ok(missing_backup_view(output));
}
ensure_safe_output_path(config, output)?;
@@ -125,6 +116,30 @@ pub fn backup(config: &RuntimeConfig, output: &Path) -> Result<LocalBackupView,
})
}
+pub fn backup_preflight(
+ config: &RuntimeConfig,
+ output: &Path,
+) -> Result<LocalBackupView, RuntimeError> {
+ if !config.local.replica_db_path.exists() {
+ return Ok(missing_backup_view(output));
+ }
+
+ ensure_safe_output_path(config, output)?;
+ let executor = SqliteExecutor::open(&config.local.replica_db_path)?;
+ let manifest = export_manifest(&executor)?;
+
+ Ok(LocalBackupView {
+ state: "dry_run".to_owned(),
+ source: LOCAL_SOURCE.to_owned(),
+ file: output.display().to_string(),
+ size_bytes: 0,
+ backup_format_version: manifest.backup_format_version,
+ replica_db_version: manifest.replica_db_version,
+ reason: Some("dry run requested; backup file was not written".to_owned()),
+ actions: vec!["radroots store backup create".to_owned()],
+ })
+}
+
pub fn export(
config: &RuntimeConfig,
format: LocalExportFormatArg,
@@ -224,6 +239,19 @@ fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> {
Ok(())
}
+fn missing_backup_view(output: &Path) -> LocalBackupView {
+ LocalBackupView {
+ state: "unconfigured".to_owned(),
+ source: LOCAL_SOURCE.to_owned(),
+ file: output.display().to_string(),
+ size_bytes: 0,
+ backup_format_version: String::new(),
+ replica_db_version: String::new(),
+ reason: Some("local replica database is not initialized".to_owned()),
+ actions: vec!["radroots store init".to_owned()],
+ }
+}
+
fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -143,6 +143,140 @@ fn local_account_selection_and_invocation_override_resolve_signer_actor() {
}
#[test]
+fn account_import_dry_run_validates_profile_without_mutating_store() {
+ let sandbox = RadrootsCliSandbox::new();
+ let public_identity = identity_public(21);
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "dry-run-import", &public_identity);
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert_eq!(value["operation_id"], "account.import");
+ assert_eq!(value["dry_run"], true);
+ assert_eq!(value["result"]["state"], "dry_run");
+ assert_eq!(
+ value["result"]["account"]["id"],
+ public_identity.id.as_str()
+ );
+ assert_eq!(value["result"]["account"]["signer"], "watch_only");
+ assert_eq!(value["result"]["account"]["is_default"], true);
+
+ let list = sandbox.json_success(&["--format", "json", "account", "list"]);
+ assert_eq!(list["result"]["count"], 0);
+}
+
+#[test]
+fn account_import_dry_run_validates_missing_profile_file() {
+ let sandbox = RadrootsCliSandbox::new();
+ let missing = sandbox.root().join("missing-account.json");
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "import",
+ missing.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "account.import");
+ assert_eq!(value["errors"][0]["code"], "not_found");
+ assert_eq!(value["errors"][0]["exit_code"], 4);
+}
+
+#[test]
+fn account_remove_dry_run_validates_selector_without_mutating_store() {
+ let sandbox = RadrootsCliSandbox::new();
+ let created = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let account_id = created["result"]["account"]["id"]
+ .as_str()
+ .expect("account id");
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "remove",
+ account_id,
+ ]);
+
+ assert_eq!(value["operation_id"], "account.remove");
+ assert_eq!(value["result"]["state"], "dry_run");
+ assert_eq!(value["result"]["removed_account"]["id"], account_id);
+ assert_eq!(value["result"]["default_would_clear"], true);
+ assert_eq!(value["result"]["remaining_account_count"], 0);
+
+ let get = sandbox.json_success(&["--format", "json", "account", "get", account_id]);
+ assert_eq!(get["result"]["state"], "ready");
+ assert_eq!(
+ get["result"]["account_resolution"]["resolved_account"]["id"],
+ account_id
+ );
+}
+
+#[test]
+fn account_selection_update_dry_run_validates_selector_without_mutating_selection() {
+ let sandbox = RadrootsCliSandbox::new();
+ let first = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let first_account_id = first["result"]["account"]["id"]
+ .as_str()
+ .expect("first account id");
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "selection",
+ "update",
+ second_account_id,
+ ]);
+
+ assert_eq!(value["operation_id"], "account.selection.update");
+ assert_eq!(value["result"]["state"], "dry_run");
+ assert_eq!(value["result"]["account"]["id"], second_account_id);
+
+ let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]);
+ assert_eq!(
+ selected["result"]["account_resolution"]["resolved_account"]["id"],
+ first_account_id
+ );
+}
+
+#[test]
+fn account_selection_update_dry_run_rejects_missing_selector() {
+ let sandbox = RadrootsCliSandbox::new();
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "account",
+ "selection",
+ "update",
+ "missing-account",
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "account.selection.update");
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["exit_code"], 5);
+}
+
+#[test]
fn unresolved_account_override_returns_account_failure() {
let sandbox = RadrootsCliSandbox::new();
let (output, value) = sandbox.json_output(&[
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -1,6 +1,7 @@
mod support;
use std::fs;
+use std::path::Path;
use serde_json::Value;
@@ -408,6 +409,44 @@ fn online_allows_local_diagnostics() {
}
#[test]
+fn store_export_dry_run_is_structured_unsupported() {
+ let sandbox = RadrootsCliSandbox::new();
+ let (output, value) =
+ sandbox.json_output(&["--format", "json", "--dry-run", "store", "export"]);
+
+ assert!(!output.status.success());
+ assert_eq!(output.status.code(), Some(2));
+ assert_eq!(value["operation_id"], "store.export");
+ assert_eq!(value["errors"][0]["code"], "invalid_input");
+ assert_eq!(value["errors"][0]["exit_code"], 2);
+}
+
+#[test]
+fn store_backup_dry_run_preflights_initialized_store_without_writing_file() {
+ let sandbox = RadrootsCliSandbox::new();
+ let (missing_output, missing_value) =
+ sandbox.json_output(&["--format", "json", "--dry-run", "store", "backup", "create"]);
+
+ assert!(!missing_output.status.success());
+ assert_eq!(missing_value["operation_id"], "store.backup.create");
+ assert_eq!(missing_value["errors"][0]["code"], "operation_unavailable");
+ assert_eq!(missing_value["errors"][0]["exit_code"], 3);
+
+ let init = sandbox.json_success(&["--format", "json", "store", "init"]);
+ assert_eq!(init["operation_id"], "store.init");
+
+ let backup =
+ sandbox.json_success(&["--format", "json", "--dry-run", "store", "backup", "create"]);
+ let file = backup["result"]["file"].as_str().expect("backup file");
+
+ assert_eq!(backup["operation_id"], "store.backup.create");
+ assert_eq!(backup["dry_run"], true);
+ assert_eq!(backup["result"]["state"], "dry_run");
+ assert_eq!(backup["result"]["size_bytes"], 0);
+ assert!(!Path::new(file).exists());
+}
+
+#[test]
fn required_approval_missing_token_returns_structured_error() {
let output = radroots()
.args(["--format", "json", "order", "submit"])