From b1702451462f5f4a854f284c5fc948342519b1a1 Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 14:05:43 +0100 Subject: [PATCH 1/7] Add output format CLI option with JSON, TOML, and table support Introduce `OutputFormatType` enum and `--format` flag to let users choose between JSON (default), table, and TOML output. Thread the format through all command handlers, the REPL, and `FormatOutput`. Table rendering serializes values to JSON, extracts columns from record objects, and falls back to JSON for non-tabular data. --- src/commands.rs | 14 ++++++ src/handlers/key.rs | 10 ++--- src/handlers/offline.rs | 42 +++++++++--------- src/handlers/online.rs | 23 ++++++---- src/handlers/repl.rs | 11 +++-- src/main.rs | 21 ++++----- src/utils/output.rs | 96 ++++++++++++++++++++++++++++++++++++++--- 7 files changed, 163 insertions(+), 54 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 975f33be..40d2197a 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -86,11 +86,25 @@ pub struct CliOpts { /// Default value : ~/.bdk-bitcoin #[arg(env = "DATADIR", short = 'd', long = "datadir")] pub datadir: Option, + /// Sets the output format. + #[arg(env = "FORMAT", short = 'f', long = "format", default_value = "json")] + pub format: OutputFormatType, /// Top level cli sub-commands. #[command(subcommand)] pub subcommand: CliSubCommand, } +/// Supported output formats. +#[derive(Clone, Debug, PartialEq, Eq, clap::ValueEnum)] +pub enum OutputFormatType { + /// JSON output. + Json, + /// Table output. + Table, + /// TOML output. + Toml, +} + /// Top level cli sub-commands. #[derive(Debug, Subcommand, Clone, PartialEq)] #[command(rename_all = "snake")] diff --git a/src/handlers/key.rs b/src/handlers/key.rs index e7bb6457..3f1b108f 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -1,4 +1,4 @@ -use crate::commands::KeySubCommand; +use crate::commands::{KeySubCommand, OutputFormatType}; use crate::error::BDKCliError as Error; use crate::handlers::{AppCommand, AppContext, Init}; use crate::utils::{output::FormatOutput, types::KeyResult}; @@ -12,17 +12,17 @@ use bdk_wallet::miniscript::{self, Segwitv0}; use clap::Parser; impl KeySubCommand { - pub fn execute(&self, ctx: &mut AppContext) -> Result<(), Error> { + pub fn execute(&self, ctx: &mut AppContext, format: OutputFormatType) -> Result<(), Error> { match self { KeySubCommand::Generate(generate_key_command) => generate_key_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), KeySubCommand::Restore(restore_key_command) => restore_key_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), KeySubCommand::Derive(derive_key_command) => derive_key_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), } } } diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index fe4097e4..597ebea8 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -1,4 +1,4 @@ -use crate::commands::OfflineWalletSubCommand; +use crate::commands::{OfflineWalletSubCommand, OutputFormatType}; use crate::error::BDKCliError as Error; use crate::handlers::{AppCommand, AppContext, OfflineOperations}; use crate::utils::output::{FormatOutput, ListResult}; @@ -36,55 +36,55 @@ use { }; impl OfflineWalletSubCommand { - pub fn execute(&self, ctx: &mut AppContext>) -> Result<(), Error> { + pub fn execute(&self, ctx: &mut AppContext>, format: OutputFormatType) -> Result<(), Error> { match self { - Self::NewAddress(new_address) => new_address.execute(ctx)?.write_out(std::io::stdout()), - Self::Balance(balance) => balance.execute(ctx)?.write_out(std::io::stdout()), + Self::NewAddress(new_address) => new_address.execute(ctx)?.write_out(std::io::stdout(), format), + Self::Balance(balance) => balance.execute(ctx)?.write_out(std::io::stdout(), format), Self::UnusedAddress(unused_address_command) => unused_address_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), Self::Unspent(unspent_command) => { - unspent_command.execute(ctx)?.write_out(std::io::stdout()) + unspent_command.execute(ctx)?.write_out(std::io::stdout(), format) } Self::Transactions(transactions_command) => transactions_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), Self::CreateTx(createtx_command) => { - createtx_command.execute(ctx)?.write_out(std::io::stdout()) + createtx_command.execute(ctx)?.write_out(std::io::stdout(), format) } #[cfg(feature = "silent-payments")] - Self::CreateSpTx(cmd) => cmd.execute(ctx)?.write_out(std::io::stdout()), + Self::CreateSpTx(cmd) => cmd.execute(ctx)?.write_out(std::io::stdout(), format), Self::BumpFee(bumpfee_command) => { - bumpfee_command.execute(ctx)?.write_out(std::io::stdout()) + bumpfee_command.execute(ctx)?.write_out(std::io::stdout(), format) } Self::Policies(policies_command) => { - policies_command.execute(ctx)?.write_out(std::io::stdout()) + policies_command.execute(ctx)?.write_out(std::io::stdout(), format) } Self::PublicDescriptor(public_descriptor_command) => public_descriptor_command .execute(ctx)? - .write_out(std::io::stdout()), - Self::Sign(sign_command) => sign_command.execute(ctx)?.write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), + Self::Sign(sign_command) => sign_command.execute(ctx)?.write_out(std::io::stdout(), format), Self::ExtractPsbt(extract_psbt_command) => extract_psbt_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), Self::FinalizePsbt(finalize_psbt_command) => finalize_psbt_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), Self::CombinePsbt(combine_psbt_command) => combine_psbt_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), #[cfg(feature = "message_signer")] Self::SignMessage(sign_message_command) => sign_message_command .execute(ctx)? - .write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), #[cfg(feature = "message_signer")] Self::VerifyMessage(verify_message_command) => verify_message_command .execute(ctx)? - .write_out(std::io::stdout()), - Self::LockUtxo(lock_utxo) => lock_utxo.execute(ctx)?.write_out(std::io::stdout()), - Self::UnlockUtxo(unlock_utxo) => unlock_utxo.execute(ctx)?.write_out(std::io::stdout()), + .write_out(std::io::stdout(), format), + Self::LockUtxo(lock_utxo) => lock_utxo.execute(ctx)?.write_out(std::io::stdout(), format), + Self::UnlockUtxo(unlock_utxo) => unlock_utxo.execute(ctx)?.write_out(std::io::stdout(), format), Self::LockedUtxos(locked_utxos) => { - locked_utxos.execute(ctx)?.write_out(std::io::stdout()) + locked_utxos.execute(ctx)?.write_out(std::io::stdout(), format) } #[cfg(feature = "dns_payment")] Self::CreateDnsTx(_) => Err(Error::Generic( diff --git a/src/handlers/online.rs b/src/handlers/online.rs index f2900cbc..a07b7baf 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -4,6 +4,13 @@ use clap::Parser; use crate::client::BlockchainClient::Electrum; #[cfg(feature = "cbf")] use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] +use crate::commands::OutputFormatType; #[cfg(feature = "esplora")] use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; #[cfg(feature = "rpc")] @@ -44,35 +51,35 @@ use { feature = "rpc" ))] impl OnlineWalletSubCommand { - pub async fn execute(&self, ctx: &mut AppContext>) -> Result<(), Error> { + pub async fn execute(&self, ctx: &mut AppContext>, format: OutputFormatType) -> Result<(), Error> { match self { OnlineWalletSubCommand::FullScan(full_scan_command) => { let response: StatusResult = full_scan_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::Sync(sync_command) => { let response: StatusResult = sync_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::Broadcast(broadcast_command) => { let response: TransactionResult = broadcast_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::ReceivePayjoin(receive_payjoin_command) => { let response: StatusResult = receive_payjoin_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::SendPayjoin(send_payjoin_command) => { let response: StatusResult = send_payjoin_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::ResumePayjoin(resume_payjoin_command) => { let response: StatusResult = resume_payjoin_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } OnlineWalletSubCommand::PayjoinHistory(payjoin_history_command) => { let response: StatusResult = payjoin_history_command.execute(ctx).await?; - response.write_out(std::io::stdout()) + response.write_out(std::io::stdout(), format) } } } diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs index b15b653f..3bd9c17f 100644 --- a/src/handlers/repl.rs +++ b/src/handlers/repl.rs @@ -18,6 +18,8 @@ use { ))] use crate::client::BlockchainClient; #[cfg(feature = "repl")] +use crate::commands::OutputFormatType; +#[cfg(feature = "repl")] use {crate::commands::WalletSubCommand, crate::error::BDKCliError as Error, std::io::Write}; #[cfg(feature = "repl")] @@ -40,6 +42,7 @@ pub(crate) async fn respond( feature = "cbf" ))] wallet_name: &str, + format: OutputFormatType, ) -> Result { let args = shlex::split(line).ok_or("error: Invalid quoting".to_string())?; @@ -55,7 +58,7 @@ pub(crate) async fn respond( ReplSubCommand::Wallet { subcommand } => match subcommand { WalletSubCommand::OfflineWalletSubCommand(cmd) => { let mut ctx = AppContext::new_offline_wallet(network, datadir, wallet); - cmd.execute(&mut ctx).map_err(|e| e.to_string())?; + cmd.execute(&mut ctx, format).map_err(|e| e.to_string())?; Some(()) } #[cfg(any( @@ -74,7 +77,7 @@ pub(crate) async fn respond( wallet_name.to_string(), ); - cmd.execute(&mut ctx).await.map_err(|e| e.to_string())?; + cmd.execute(&mut ctx, format).await.map_err(|e| e.to_string())?; Some(()) } WalletSubCommand::Config(_) => { @@ -92,14 +95,14 @@ pub(crate) async fn respond( let mut ctx = AppContext::new(network, datadir); cmd.execute(&mut ctx) .map_err(|e| e.to_string())? - .write_out(std::io::stdout()) + .write_out(std::io::stdout(), format) .map_err(|e| e.to_string())?; Some(()) } ReplSubCommand::Key { subcommand } => { let mut ctx = AppContext::new(network, datadir); - subcommand.execute(&mut ctx).map_err(|e| e.to_string())?; + subcommand.execute(&mut ctx, format).map_err(|e| e.to_string())?; Some(()) } diff --git a/src/main.rs b/src/main.rs index 06e3ea24..ed24bbc6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -78,7 +78,7 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { runtime.wallet_name.clone(), ); - cmd.execute(&mut ctx).await?; + cmd.execute(&mut ctx, cli_opts.format).await?; } wallet.persist()?; } @@ -100,9 +100,9 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { dns_cmd .execute(&mut ctx) .await? - .write_out(std::io::stdout())?; + .write_out(std::io::stdout(), cli_opts.format)?; } - other => other.execute(&mut ctx)?, + other => other.execute(&mut ctx, cli_opts.format)?, } } wallet.persist()?; @@ -113,26 +113,26 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { let mut ctx = AppContext::new(cli_opts.network, home_dir); - config_cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + config_cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; } }, CliSubCommand::Key { subcommand } => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - subcommand.execute(&mut ctx)?; + subcommand.execute(&mut ctx, cli_opts.format)?; } CliSubCommand::Descriptor(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; } CliSubCommand::Wallets(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; } #[cfg(feature = "repl")] @@ -183,6 +183,7 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { feature = "cbf" ))] &wallet_name, + cli_opts.format.clone() ) .await .map_err(Error::Generic)?; @@ -197,7 +198,7 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { CliSubCommand::Compile(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; } CliSubCommand::Completions { shell } => { clap_complete::generate( @@ -211,12 +212,12 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { CliSubCommand::SilentPaymentCode(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout())?; + cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; } #[cfg(feature = "dns_payment")] CliSubCommand::ResolveDnsRecipient(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx).await?.write_out(std::io::stdout())?; + cmd.execute(&mut ctx).await?.write_out(std::io::stdout(), cli_opts.format)?; } } diff --git a/src/utils/output.rs b/src/utils/output.rs index b2cd32a2..e18429bb 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,17 +1,101 @@ use std::io::Write; -use crate::error::BDKCliError as Error; +use crate::{commands::OutputFormatType, error::BDKCliError as Error}; use serde::Serialize; +use cli_table::{format::Justify, Cell, Style, Table}; /// A trait for types that can be presented to the user. pub trait FormatOutput: Serialize { - fn format(&self) -> Result { - serde_json::to_string_pretty(self) - .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))) + /// Formats the output according to the requested [`OutputFormatType`]. + fn format(&self, format: OutputFormatType) -> Result { + match format { + OutputFormatType::Json => serde_json::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))), + OutputFormatType::Toml => toml::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("TOML serialization failed: {e}"))), + OutputFormatType::Table => self.format_table(), + } + } + + /// Renders the output as a table. + /// + /// The value is serialized to a JSON array of records, converted into rows + /// of strings, and rendered with [`cli_table`]. When the value is not an + /// array (e.g. a single object or scalar), it is treated as a one-row table. + /// The default implementation falls back to JSON when the value cannot be + /// represented as rows. + fn format_table(&self) -> Result { + + // Serialize the value into a generic JSON representation so we can + // inspect its shape regardless of the concrete type. + let value = serde_json::to_value(self) + .map_err(|e| Error::Generic(format!("Table serialization failed: {e}")))?; + + // Normalize into a list of rows. A top-level array is treated as the + // list of rows; anything else is treated as a single row. + let rows: Vec = match value { + serde_json::Value::Array(items) => items, + other => vec![other], + }; + + // Collect the ordered set of column names from all row objects. + let mut columns: Vec = Vec::new(); + for row in &rows { + if let serde_json::Value::Object(map) = row { + for key in map.keys() { + if !columns.iter().any(|c| c == key) { + columns.push(key.clone()); + } + } + } + } + + if columns.is_empty() { + // Not a set of records; fall back to JSON. + return serde_json::to_string_pretty(self) + .map_err(|e| Error::Generic(format!("Table serialization failed: {e}"))); + } + + // Build the header row. + let header: Vec<_> = columns + .iter() + .map(|name| name.as_str().cell().bold(true).justify(Justify::Left)) + .collect(); + + // Build the body rows as string cells. + let table_rows: Vec> = rows + .iter() + .map(|row| { + columns + .iter() + .map(|column| { + let text = match row.get(column) { + Some(serde_json::Value::String(s)) => s.clone(), + Some(other) => other.to_string(), + None => String::new(), + }; + text.cell() + }) + .collect() + }) + .collect(); + + let table = table_rows + .table() + .title(header) + .display() + .map_err(|e| Error::Generic(format!("Table rendering failed: {e}")))?; + + Ok(table.to_string()) } - fn write_out(&self, mut writer: W) -> Result<(), Error> { - let output = self.format()?; + + fn write_out( + &self, + mut writer: W, + format: OutputFormatType, + ) -> Result<(), Error> { + let output = self.format(format)?; writeln!(writer, "{}", output) .map_err(|e| Error::Generic(format!("Failed to write output: {e}"))) From 2f4072f74ebe86f8dd5e22a0429806e2ef53d286 Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 15:27:05 +0100 Subject: [PATCH 2/7] Format code with rustfmt Apply rustfmt formatting to improve line breaks and import ordering across handler and utility modules. --- src/handlers/key.rs | 6 ++++- src/handlers/offline.rs | 50 +++++++++++++++++++++++++---------------- src/handlers/online.rs | 6 ++++- src/handlers/repl.rs | 8 +++++-- src/main.rs | 22 ++++++++++++------ src/utils/output.rs | 10 ++------- 6 files changed, 64 insertions(+), 38 deletions(-) diff --git a/src/handlers/key.rs b/src/handlers/key.rs index 3f1b108f..de1ef649 100644 --- a/src/handlers/key.rs +++ b/src/handlers/key.rs @@ -12,7 +12,11 @@ use bdk_wallet::miniscript::{self, Segwitv0}; use clap::Parser; impl KeySubCommand { - pub fn execute(&self, ctx: &mut AppContext, format: OutputFormatType) -> Result<(), Error> { + pub fn execute( + &self, + ctx: &mut AppContext, + format: OutputFormatType, + ) -> Result<(), Error> { match self { KeySubCommand::Generate(generate_key_command) => generate_key_command .execute(ctx)? diff --git a/src/handlers/offline.rs b/src/handlers/offline.rs index 597ebea8..c36fc913 100644 --- a/src/handlers/offline.rs +++ b/src/handlers/offline.rs @@ -36,34 +36,42 @@ use { }; impl OfflineWalletSubCommand { - pub fn execute(&self, ctx: &mut AppContext>, format: OutputFormatType) -> Result<(), Error> { + pub fn execute( + &self, + ctx: &mut AppContext>, + format: OutputFormatType, + ) -> Result<(), Error> { match self { - Self::NewAddress(new_address) => new_address.execute(ctx)?.write_out(std::io::stdout(), format), + Self::NewAddress(new_address) => new_address + .execute(ctx)? + .write_out(std::io::stdout(), format), Self::Balance(balance) => balance.execute(ctx)?.write_out(std::io::stdout(), format), Self::UnusedAddress(unused_address_command) => unused_address_command .execute(ctx)? .write_out(std::io::stdout(), format), - Self::Unspent(unspent_command) => { - unspent_command.execute(ctx)?.write_out(std::io::stdout(), format) - } + Self::Unspent(unspent_command) => unspent_command + .execute(ctx)? + .write_out(std::io::stdout(), format), Self::Transactions(transactions_command) => transactions_command .execute(ctx)? .write_out(std::io::stdout(), format), - Self::CreateTx(createtx_command) => { - createtx_command.execute(ctx)?.write_out(std::io::stdout(), format) - } + Self::CreateTx(createtx_command) => createtx_command + .execute(ctx)? + .write_out(std::io::stdout(), format), #[cfg(feature = "silent-payments")] Self::CreateSpTx(cmd) => cmd.execute(ctx)?.write_out(std::io::stdout(), format), - Self::BumpFee(bumpfee_command) => { - bumpfee_command.execute(ctx)?.write_out(std::io::stdout(), format) - } - Self::Policies(policies_command) => { - policies_command.execute(ctx)?.write_out(std::io::stdout(), format) - } + Self::BumpFee(bumpfee_command) => bumpfee_command + .execute(ctx)? + .write_out(std::io::stdout(), format), + Self::Policies(policies_command) => policies_command + .execute(ctx)? + .write_out(std::io::stdout(), format), Self::PublicDescriptor(public_descriptor_command) => public_descriptor_command .execute(ctx)? .write_out(std::io::stdout(), format), - Self::Sign(sign_command) => sign_command.execute(ctx)?.write_out(std::io::stdout(), format), + Self::Sign(sign_command) => sign_command + .execute(ctx)? + .write_out(std::io::stdout(), format), Self::ExtractPsbt(extract_psbt_command) => extract_psbt_command .execute(ctx)? .write_out(std::io::stdout(), format), @@ -81,11 +89,15 @@ impl OfflineWalletSubCommand { Self::VerifyMessage(verify_message_command) => verify_message_command .execute(ctx)? .write_out(std::io::stdout(), format), - Self::LockUtxo(lock_utxo) => lock_utxo.execute(ctx)?.write_out(std::io::stdout(), format), - Self::UnlockUtxo(unlock_utxo) => unlock_utxo.execute(ctx)?.write_out(std::io::stdout(), format), - Self::LockedUtxos(locked_utxos) => { - locked_utxos.execute(ctx)?.write_out(std::io::stdout(), format) + Self::LockUtxo(lock_utxo) => { + lock_utxo.execute(ctx)?.write_out(std::io::stdout(), format) } + Self::UnlockUtxo(unlock_utxo) => unlock_utxo + .execute(ctx)? + .write_out(std::io::stdout(), format), + Self::LockedUtxos(locked_utxos) => locked_utxos + .execute(ctx)? + .write_out(std::io::stdout(), format), #[cfg(feature = "dns_payment")] Self::CreateDnsTx(_) => Err(Error::Generic( "CreateDnsTx is dispatched asynchronously through main".to_string(), diff --git a/src/handlers/online.rs b/src/handlers/online.rs index a07b7baf..2c337f2f 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -51,7 +51,11 @@ use { feature = "rpc" ))] impl OnlineWalletSubCommand { - pub async fn execute(&self, ctx: &mut AppContext>, format: OutputFormatType) -> Result<(), Error> { + pub async fn execute( + &self, + ctx: &mut AppContext>, + format: OutputFormatType, + ) -> Result<(), Error> { match self { OnlineWalletSubCommand::FullScan(full_scan_command) => { let response: StatusResult = full_scan_command.execute(ctx).await?; diff --git a/src/handlers/repl.rs b/src/handlers/repl.rs index 3bd9c17f..0559e16c 100644 --- a/src/handlers/repl.rs +++ b/src/handlers/repl.rs @@ -77,7 +77,9 @@ pub(crate) async fn respond( wallet_name.to_string(), ); - cmd.execute(&mut ctx, format).await.map_err(|e| e.to_string())?; + cmd.execute(&mut ctx, format) + .await + .map_err(|e| e.to_string())?; Some(()) } WalletSubCommand::Config(_) => { @@ -102,7 +104,9 @@ pub(crate) async fn respond( ReplSubCommand::Key { subcommand } => { let mut ctx = AppContext::new(network, datadir); - subcommand.execute(&mut ctx, format).map_err(|e| e.to_string())?; + subcommand + .execute(&mut ctx, format) + .map_err(|e| e.to_string())?; Some(()) } diff --git a/src/main.rs b/src/main.rs index ed24bbc6..27a28be8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,7 +113,9 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { let mut ctx = AppContext::new(cli_opts.network, home_dir); - config_cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; + config_cmd + .execute(&mut ctx)? + .write_out(std::io::stdout(), cli_opts.format)?; } }, @@ -126,13 +128,15 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { CliSubCommand::Descriptor(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; + cmd.execute(&mut ctx)? + .write_out(std::io::stdout(), cli_opts.format)?; } CliSubCommand::Wallets(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; + cmd.execute(&mut ctx)? + .write_out(std::io::stdout(), cli_opts.format)?; } #[cfg(feature = "repl")] @@ -183,7 +187,7 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { feature = "cbf" ))] &wallet_name, - cli_opts.format.clone() + cli_opts.format.clone(), ) .await .map_err(Error::Generic)?; @@ -198,7 +202,8 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { CliSubCommand::Compile(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; + cmd.execute(&mut ctx)? + .write_out(std::io::stdout(), cli_opts.format)?; } CliSubCommand::Completions { shell } => { clap_complete::generate( @@ -212,12 +217,15 @@ async fn run(cli_opts: CliOpts) -> Result<(), Error> { CliSubCommand::SilentPaymentCode(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx)?.write_out(std::io::stdout(), cli_opts.format)?; + cmd.execute(&mut ctx)? + .write_out(std::io::stdout(), cli_opts.format)?; } #[cfg(feature = "dns_payment")] CliSubCommand::ResolveDnsRecipient(cmd) => { let mut ctx = AppContext::new(cli_opts.network, home_dir); - cmd.execute(&mut ctx).await?.write_out(std::io::stdout(), cli_opts.format)?; + cmd.execute(&mut ctx) + .await? + .write_out(std::io::stdout(), cli_opts.format)?; } } diff --git a/src/utils/output.rs b/src/utils/output.rs index e18429bb..62aaa214 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,8 +1,8 @@ use std::io::Write; use crate::{commands::OutputFormatType, error::BDKCliError as Error}; +use cli_table::{Cell, Style, Table, format::Justify}; use serde::Serialize; -use cli_table::{format::Justify, Cell, Style, Table}; /// A trait for types that can be presented to the user. pub trait FormatOutput: Serialize { @@ -25,7 +25,6 @@ pub trait FormatOutput: Serialize { /// The default implementation falls back to JSON when the value cannot be /// represented as rows. fn format_table(&self) -> Result { - // Serialize the value into a generic JSON representation so we can // inspect its shape regardless of the concrete type. let value = serde_json::to_value(self) @@ -89,12 +88,7 @@ pub trait FormatOutput: Serialize { Ok(table.to_string()) } - - fn write_out( - &self, - mut writer: W, - format: OutputFormatType, - ) -> Result<(), Error> { + fn write_out(&self, mut writer: W, format: OutputFormatType) -> Result<(), Error> { let output = self.format(format)?; writeln!(writer, "{}", output) From ff784efa667957726f4a327b57d77811f12ebfdb Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 16:04:16 +0100 Subject: [PATCH 3/7] Refactor table output to use cli_table's Display trait Replace the manual JSON-to-table rendering with cli_table's `print_stdout`, requiring `Table` to be implemented on output types. Table formatting now writes directly to stdout via `print_stdout` instead of being serialized into a string, so the `format` method returns an empty string for `OutputFormatType::Table`. --- src/utils/output.rs | 90 +++++++-------------------------------------- 1 file changed, 14 insertions(+), 76 deletions(-) diff --git a/src/utils/output.rs b/src/utils/output.rs index 62aaa214..933b1649 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,11 +1,11 @@ use std::io::Write; use crate::{commands::OutputFormatType, error::BDKCliError as Error}; -use cli_table::{Cell, Style, Table, format::Justify}; +use cli_table::{Table, print_stdout}; use serde::Serialize; /// A trait for types that can be presented to the user. -pub trait FormatOutput: Serialize { +pub trait FormatOutput: Serialize + Table { /// Formats the output according to the requested [`OutputFormatType`]. fn format(&self, format: OutputFormatType) -> Result { match format { @@ -13,90 +13,28 @@ pub trait FormatOutput: Serialize { .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))), OutputFormatType::Toml => toml::to_string_pretty(self) .map_err(|e| Error::Generic(format!("TOML serialization failed: {e}"))), - OutputFormatType::Table => self.format_table(), + OutputFormatType::Table => Ok("".into()), } } - /// Renders the output as a table. - /// - /// The value is serialized to a JSON array of records, converted into rows - /// of strings, and rendered with [`cli_table`]. When the value is not an - /// array (e.g. a single object or scalar), it is treated as a one-row table. - /// The default implementation falls back to JSON when the value cannot be - /// represented as rows. - fn format_table(&self) -> Result { - // Serialize the value into a generic JSON representation so we can - // inspect its shape regardless of the concrete type. - let value = serde_json::to_value(self) - .map_err(|e| Error::Generic(format!("Table serialization failed: {e}")))?; - // Normalize into a list of rows. A top-level array is treated as the - // list of rows; anything else is treated as a single row. - let rows: Vec = match value { - serde_json::Value::Array(items) => items, - other => vec![other], - }; - - // Collect the ordered set of column names from all row objects. - let mut columns: Vec = Vec::new(); - for row in &rows { - if let serde_json::Value::Object(map) = row { - for key in map.keys() { - if !columns.iter().any(|c| c == key) { - columns.push(key.clone()); - } - } + fn write_out(&self, mut writer: W, format: OutputFormatType) -> Result<(), Error> { + match format { + OutputFormatType::Table => { + print_stdout(vec![self.clone()].table()?); + } + _ => { + let output = self.format(format)?; + writeln!(writer, "{}", output) + .map_err(|e| Error::Generic(format!("Failed to write output: {e}")))?; } } - if columns.is_empty() { - // Not a set of records; fall back to JSON. - return serde_json::to_string_pretty(self) - .map_err(|e| Error::Generic(format!("Table serialization failed: {e}"))); - } - - // Build the header row. - let header: Vec<_> = columns - .iter() - .map(|name| name.as_str().cell().bold(true).justify(Justify::Left)) - .collect(); - - // Build the body rows as string cells. - let table_rows: Vec> = rows - .iter() - .map(|row| { - columns - .iter() - .map(|column| { - let text = match row.get(column) { - Some(serde_json::Value::String(s)) => s.clone(), - Some(other) => other.to_string(), - None => String::new(), - }; - text.cell() - }) - .collect() - }) - .collect(); - - let table = table_rows - .table() - .title(header) - .display() - .map_err(|e| Error::Generic(format!("Table rendering failed: {e}")))?; - - Ok(table.to_string()) - } - - fn write_out(&self, mut writer: W, format: OutputFormatType) -> Result<(), Error> { - let output = self.format(format)?; - - writeln!(writer, "{}", output) - .map_err(|e| Error::Generic(format!("Failed to write output: {e}"))) + Ok(()) } } -impl FormatOutput for T {} +impl FormatOutput for T {} /// A generic wrapper for commands that return a list of items. #[derive(Serialize)] From 541a9eb02522a157b4afa07cf1a7aa5bda502aa0 Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 16:40:52 +0100 Subject: [PATCH 4/7] Remove table output format The `cli_table` dependency is no longer used for formatting output, leaving only JSON and TOML formats available. --- src/commands.rs | 2 -- src/utils/output.rs | 21 +++++---------------- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index d2dd48b8..aa24cf7b 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -99,8 +99,6 @@ pub struct CliOpts { pub enum OutputFormatType { /// JSON output. Json, - /// Table output. - Table, /// TOML output. Toml, } diff --git a/src/utils/output.rs b/src/utils/output.rs index 933b1649..071c4381 100644 --- a/src/utils/output.rs +++ b/src/utils/output.rs @@ -1,11 +1,10 @@ use std::io::Write; use crate::{commands::OutputFormatType, error::BDKCliError as Error}; -use cli_table::{Table, print_stdout}; use serde::Serialize; /// A trait for types that can be presented to the user. -pub trait FormatOutput: Serialize + Table { +pub trait FormatOutput: Serialize { /// Formats the output according to the requested [`OutputFormatType`]. fn format(&self, format: OutputFormatType) -> Result { match format { @@ -13,28 +12,18 @@ pub trait FormatOutput: Serialize + Table { .map_err(|e| Error::Generic(format!("JSON serialization failed: {e}"))), OutputFormatType::Toml => toml::to_string_pretty(self) .map_err(|e| Error::Generic(format!("TOML serialization failed: {e}"))), - OutputFormatType::Table => Ok("".into()), } } fn write_out(&self, mut writer: W, format: OutputFormatType) -> Result<(), Error> { - match format { - OutputFormatType::Table => { - print_stdout(vec![self.clone()].table()?); - } - _ => { - let output = self.format(format)?; - writeln!(writer, "{}", output) - .map_err(|e| Error::Generic(format!("Failed to write output: {e}")))?; - } - } - - Ok(()) + let output = self.format(format)?; + writeln!(writer, "{}", output) + .map_err(|e| Error::Generic(format!("Failed to write output: {e}"))) } } -impl FormatOutput for T {} +impl FormatOutput for T {} /// A generic wrapper for commands that return a list of items. #[derive(Serialize)] From f5fec6d4907695d0d49a950f0a8d5dd723f0013b Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 16:46:45 +0100 Subject: [PATCH 5/7] Remove unnecessary cfg attribute from OutputFormatType import --- src/handlers/online.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/handlers/online.rs b/src/handlers/online.rs index 2c337f2f..79833442 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -4,12 +4,6 @@ use clap::Parser; use crate::client::BlockchainClient::Electrum; #[cfg(feature = "cbf")] use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client}; -#[cfg(any( - feature = "electrum", - feature = "esplora", - feature = "cbf", - feature = "rpc" -))] use crate::commands::OutputFormatType; #[cfg(feature = "esplora")] use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; From 91787264eed2bcb8442b51fa0772fb8ec5b83267 Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 16:48:29 +0100 Subject: [PATCH 6/7] Add conditional compilation to OutputFormatType import --- src/handlers/online.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/handlers/online.rs b/src/handlers/online.rs index 79833442..2c337f2f 100644 --- a/src/handlers/online.rs +++ b/src/handlers/online.rs @@ -4,6 +4,12 @@ use clap::Parser; use crate::client::BlockchainClient::Electrum; #[cfg(feature = "cbf")] use crate::client::{BlockchainClient::KyotoClient, sync_kyoto_client}; +#[cfg(any( + feature = "electrum", + feature = "esplora", + feature = "cbf", + feature = "rpc" +))] use crate::commands::OutputFormatType; #[cfg(feature = "esplora")] use {crate::client::BlockchainClient::Esplora, bdk_esplora::EsploraAsyncExt}; From 71681a3b2f39c7479bcf74db382091975c3c4c4f Mon Sep 17 00:00:00 2001 From: emma31-dev Date: Sat, 12 Sep 2026 16:54:03 +0100 Subject: [PATCH 7/7] Add description of multi-format support to CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 098eee19..c9ed1976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ page. See [DEVELOPMENT_CYCLE.md](DEVELOPMENT_CYCLE.md) for more details. ## [Unreleased] - Added support for Multipath (two-paths) descriptors. - +- Support for JSON and TOML format through `--format` flag. ## [4.0.0]