cli

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

commit 488476f70d1b3d18bfacf609dd51d47b63f6519d
parent 74b1e7d6e82b6db20eceb3ea659400d165a63e5f
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 02:35:00 +0000

cli: type account write readiness

- render account summaries from runtime custody facts
- stop deriving write capability from signer labels
- map missing listing seller accounts through typed validation
- cover account and listing gate behavior with focused tests

Diffstat:
Msrc/domain/runtime.rs | 12++++--------
Msrc/runtime/accounts.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Msrc/runtime/listing.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
3 files changed, 109 insertions(+), 43 deletions(-)

diff --git a/src/domain/runtime.rs b/src/domain/runtime.rs @@ -451,22 +451,18 @@ pub struct AccountSummaryView { } impl AccountSummaryView { - pub fn from_account_record( + pub fn from_account_runtime( record: &RadrootsNostrAccountRecord, signer: &str, + custody: &str, + write_capable: bool, is_default: bool, ) -> Self { - let write_capable = signer == "local"; Self { id: record.account_id.to_string(), display_name: record.label.clone(), signer: signer.to_owned(), - custody: if write_capable { - "secret_backed" - } else { - "watch_only" - } - .to_owned(), + custody: custody.to_owned(), write_capable, is_default, } diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -66,7 +66,36 @@ pub struct AccountSnapshot { pub struct AccountRecordView { pub record: RadrootsNostrAccountRecord, pub is_default: bool, - pub signer: &'static str, + pub custody: AccountCustody, + pub write_capable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountCustody { + SecretBacked, + WatchOnly, +} + +impl AccountCustody { + pub fn as_str(self) -> &'static str { + match self { + Self::SecretBacked => "secret_backed", + Self::WatchOnly => "watch_only", + } + } + + pub fn signer_label(self) -> &'static str { + match self { + Self::SecretBacked => "local", + Self::WatchOnly => "watch_only", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AccountRuntimeFacts { + custody: AccountCustody, + write_capable: bool, } #[derive(Debug, Clone)] @@ -207,7 +236,8 @@ pub fn preview_public_identity_import( Ok(AccountRecordView { record: RadrootsNostrAccountRecord::new(public_identity, None, 0), is_default: make_default, - signer: "watch_only", + custody: AccountCustody::WatchOnly, + write_capable: false, }) } @@ -225,7 +255,8 @@ pub fn preview_identity_secret_attachment( if make_default { account.is_default = true; } - account.signer = "local"; + account.custody = AccountCustody::SecretBacked; + account.write_capable = true; Ok(account) } @@ -406,7 +437,13 @@ pub fn resolve_local_signing_identity( } pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { - AccountSummaryView::from_account_record(&account.record, account.signer, account.is_default) + AccountSummaryView::from_account_runtime( + &account.record, + account.custody.signer_label(), + account.custody.as_str(), + account.write_capable, + account.is_default, + ) } pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView { @@ -478,11 +515,12 @@ fn snapshot_from_manager( let is_default = default_account_id .as_deref() .is_some_and(|default| default == record.account_id.as_str()); - let signer = account_signer(manager, &record)?; + let runtime = account_runtime_facts(manager, &record)?; accounts.push(AccountRecordView { record, is_default, - signer, + custody: runtime.custody, + write_capable: runtime.write_capable, }); } @@ -548,15 +586,21 @@ fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> } } -fn account_signer( +fn account_runtime_facts( manager: &RadrootsNostrAccountsManager, record: &RadrootsNostrAccountRecord, -) -> Result<&'static str, RuntimeError> { +) -> Result<AccountRuntimeFacts, RuntimeError> { Ok( if manager.get_signing_identity(&record.account_id)?.is_some() { - "local" + AccountRuntimeFacts { + custody: AccountCustody::SecretBacked, + write_capable: true, + } } else { - "watch_only" + AccountRuntimeFacts { + custody: AccountCustody::WatchOnly, + write_capable: false, + } }, ) } diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -201,6 +201,26 @@ struct LoadedListingDraft { document: ListingDraftDocument, } +#[derive(Debug, Clone)] +enum ListingDraftValidationError { + Issue(ListingValidationIssueView), + MissingSellerAccount(ListingValidationIssueView), +} + +impl ListingDraftValidationError { + fn into_issue(self) -> ListingValidationIssueView { + match self { + Self::Issue(issue) | Self::MissingSellerAccount(issue) => issue, + } + } +} + +impl From<ListingValidationIssueView> for ListingDraftValidationError { + fn from(issue: ListingValidationIssueView) -> Self { + Self::Issue(issue) + } +} + #[derive(Debug, Clone, Copy)] pub enum ListingMutationOperation { Publish, @@ -508,11 +528,11 @@ pub fn validate( )), } } - Err(issue) => Ok(invalid_validation_view( + Err(error) => Ok(invalid_validation_view( args.file.as_path(), parsed.listing.d_tag.as_str(), &context, - issue, + error.into_issue(), )), } } @@ -617,7 +637,7 @@ fn summary_from_loaded( state = "ready"; } } - Err(issue) => issues.push(issue), + Err(error) => issues.push(error.into_issue()), } } Err(reason) => issues.push(ListingValidationIssueView { @@ -820,18 +840,17 @@ fn mutate( )) })?; let context = validation_context(config)?; - let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|issue| { - if issue.field == "listing.seller_pubkey" - && issue - .message - .contains("no resolved account pubkey is available") - { - return accounts::AccountRuntimeFailure::unresolved(format!( - "{} ({})", - issue.message, issue.field - )) - .into(); - } + let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { + let issue = match error { + ListingDraftValidationError::MissingSellerAccount(issue) => { + return accounts::AccountRuntimeFailure::unresolved(format!( + "{} ({})", + issue.message, issue.field + )) + .into(); + } + ListingDraftValidationError::Issue(issue) => issue, + }; RuntimeError::Config(format!( "invalid listing draft {}: {} ({})", args.file.display(), @@ -984,20 +1003,22 @@ fn canonicalize_draft( draft: &ListingDraftDocument, contents: &str, context: &ListingValidationContext, -) -> Result<CanonicalListingDraft, ListingValidationIssueView> { +) -> Result<CanonicalListingDraft, ListingDraftValidationError> { if draft.version != 1 { return Err(issue_for_field( contents, "version", format!("unsupported listing draft version `{}`", draft.version), - )); + ) + .into()); } if draft.kind.trim() != DRAFT_KIND { return Err(issue_for_field( contents, "kind", format!("unsupported listing draft kind `{}`", draft.kind), - )); + ) + .into()); } let listing_id = draft.listing.d_tag.trim().to_owned(); @@ -1006,7 +1027,8 @@ fn canonicalize_draft( contents, "listing.d_tag", "listing d_tag must be a 22-character base64url identifier", - )); + ) + .into()); } let seller_pubkey = if let Some(pubkey) = non_empty(draft.listing.seller_pubkey.clone()) { @@ -1014,10 +1036,12 @@ fn canonicalize_draft( } else if let Some(pubkey) = context.selected_account_pubkey.clone() { pubkey } else { - return Err(issue_for_field( - contents, - "listing.seller_pubkey", - "missing seller_pubkey and no resolved account pubkey is available", + return Err(ListingDraftValidationError::MissingSellerAccount( + issue_for_field( + contents, + "listing.seller_pubkey", + "missing seller_pubkey and no resolved account pubkey is available", + ), )); }; @@ -1030,14 +1054,16 @@ fn canonicalize_draft( contents, "listing.farm_d_tag", "missing farm_d_tag and no selected farm config is available", - )); + ) + .into()); }; if !is_d_tag_base64url(&farm_d_tag) { return Err(issue_for_field( contents, "listing.farm_d_tag", "farm_d_tag must be a 22-character base64url identifier", - )); + ) + .into()); } let quantity_amount = parse_decimal_field(