]> Untitled Git - bdk-cli/commitdiff
refactor(handlers): Add types for outputting data
authorVihiga Tyonum <withtvpeter@gmail.com>
Fri, 24 Apr 2026 18:41:23 +0000 (19:41 +0100)
committerVihiga Tyonum <withtvpeter@gmail.com>
Fri, 5 Jun 2026 16:12:21 +0000 (17:12 +0100)
- add trait for formatting outputs
- add types for presenting data to the output
- refactor offline mod to use types

src/handlers/mod.rs
src/handlers/offline.rs
src/handlers/types.rs [new file with mode: 0644]
src/utils/mod.rs
src/utils/output.rs [new file with mode: 0644]

index f7a4737e307857ac57922b7707d900d1457f0403..7fb0179df98af70feae7e9ff7e93ad481d7a894f 100644 (file)
@@ -4,6 +4,7 @@ pub mod key;
 pub mod offline;
 pub mod online;
 pub mod repl;
+pub mod types;
 pub mod wallets;
 
 #[cfg(feature = "repl")]
index 9d564ebb8440ac1f2497c437cc64e7789e852fdd..63994139bc13bd054f1bcd376ffb5a7a40fa9e8d 100644 (file)
@@ -1,16 +1,17 @@
 use crate::commands::OfflineWalletSubCommand::*;
 use crate::commands::{CliOpts, OfflineWalletSubCommand, WalletOpts};
 use crate::error::BDKCliError as Error;
-use crate::utils::shorten;
+use crate::handlers::types::{
+    AddressResult, BalanceResult, PoliciesResult, PsbtResult, PublicDescriptorResult, RawPsbt,
+    TransactionDetails, TransactionListResult, UnspentDetails, UnspentListResult,
+};
+use crate::utils::output::FormatOutput;
 use bdk_wallet::bitcoin::base64::Engine;
 use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
 use bdk_wallet::bitcoin::consensus::encode::serialize_hex;
 use bdk_wallet::bitcoin::script::PushBytesBuf;
-use bdk_wallet::bitcoin::{Address, Amount, FeeRate, Psbt, Sequence, Txid};
-use bdk_wallet::chain::ChainPosition;
+use bdk_wallet::bitcoin::{Amount, FeeRate, Psbt, Sequence, Txid};
 use bdk_wallet::{KeychainKind, SignOptions, Wallet};
-use cli_table::format::Justify;
-use cli_table::{Cell, CellStruct, Style, Table};
 use serde_json::json;
 use std::collections::BTreeMap;
 use std::str::FromStr;
@@ -24,232 +25,62 @@ pub fn handle_offline_wallet_subcommand(
     cli_opts: &CliOpts,
     offline_subcommand: OfflineWalletSubCommand,
 ) -> Result<String, Error> {
+    let pretty = cli_opts.pretty;
     match offline_subcommand {
         NewAddress => {
             let addr = wallet.reveal_next_address(KeychainKind::External);
-            if cli_opts.pretty {
-                let table = vec![
-                    vec!["Address".cell().bold(true), addr.address.to_string().cell()],
-                    vec![
-                        "Index".cell().bold(true),
-                        addr.index.to_string().cell().justify(Justify::Right),
-                    ],
-                ]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else if wallet_opts.verbose {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "address": addr.address,
-                    "index": addr.index
-                }))?)
-            } else {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "address": addr.address,
-                }))?)
-            }
+            let result: AddressResult = addr.into();
+            result.format(pretty)
         }
         UnusedAddress => {
             let addr = wallet.next_unused_address(KeychainKind::External);
-
-            if cli_opts.pretty {
-                let table = vec![
-                    vec!["Address".cell().bold(true), addr.address.to_string().cell()],
-                    vec![
-                        "Index".cell().bold(true),
-                        addr.index.to_string().cell().justify(Justify::Right),
-                    ],
-                ]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else if wallet_opts.verbose {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "address": addr.address,
-                    "index": addr.index
-                }))?)
-            } else {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "address": addr.address,
-                }))?)
-            }
+            let result: AddressResult = addr.into();
+            result.format(pretty)
         }
         Unspent => {
-            let utxos = wallet.list_unspent().collect::<Vec<_>>();
-            if cli_opts.pretty {
-                let mut rows: Vec<Vec<CellStruct>> = vec![];
-                for utxo in &utxos {
-                    let height = utxo
-                        .chain_position
-                        .confirmation_height_upper_bound()
-                        .map(|h| h.to_string())
-                        .unwrap_or("Pending".to_string());
+            let utxos: Vec<UnspentDetails> = wallet
+                .list_unspent()
+                .map(|utxo| UnspentDetails::from_local_output(&utxo, cli_opts.network))
+                .collect();
 
-                    let block_hash = match &utxo.chain_position {
-                        ChainPosition::Confirmed { anchor, .. } => anchor.block_id.hash.to_string(),
-                        ChainPosition::Unconfirmed { .. } => "Unconfirmed".to_string(),
-                    };
-
-                    rows.push(vec![
-                        shorten(utxo.outpoint, 8, 10).cell(),
-                        utxo.txout
-                            .value
-                            .to_sat()
-                            .to_string()
-                            .cell()
-                            .justify(Justify::Right),
-                        Address::from_script(&utxo.txout.script_pubkey, cli_opts.network)
-                            .unwrap()
-                            .cell(),
-                        utxo.keychain.cell(),
-                        utxo.is_spent.cell(),
-                        utxo.derivation_index.cell(),
-                        height.to_string().cell().justify(Justify::Right),
-                        shorten(block_hash, 8, 8).cell().justify(Justify::Right),
-                    ]);
-                }
-                let table = rows
-                    .table()
-                    .title(vec![
-                        "Outpoint".cell().bold(true),
-                        "Output (sat)".cell().bold(true),
-                        "Output Address".cell().bold(true),
-                        "Keychain".cell().bold(true),
-                        "Is Spent".cell().bold(true),
-                        "Index".cell().bold(true),
-                        "Block Height".cell().bold(true),
-                        "Block Hash".cell().bold(true),
-                    ])
-                    .display()
-                    .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                Ok(serde_json::to_string_pretty(&utxos)?)
-            }
+            let result = UnspentListResult(utxos);
+            result.format(pretty)
         }
         Transactions => {
             let transactions = wallet.transactions();
 
-            if cli_opts.pretty {
-                let txns = transactions
-                    .map(|tx| {
-                        let total_value = tx
-                            .tx_node
-                            .output
-                            .iter()
-                            .map(|output| output.value.to_sat())
-                            .sum::<u64>();
-                        (
-                            tx.tx_node.txid.to_string(),
-                            tx.tx_node.version,
-                            tx.tx_node.is_explicitly_rbf(),
-                            tx.tx_node.input.len(),
-                            tx.tx_node.output.len(),
-                            total_value,
-                        )
-                    })
-                    .collect::<Vec<_>>();
-                let mut rows: Vec<Vec<CellStruct>> = vec![];
-                for (txid, version, is_rbf, input_count, output_count, total_value) in txns {
-                    rows.push(vec![
-                        txid.cell(),
-                        version.to_string().cell().justify(Justify::Right),
-                        is_rbf.to_string().cell().justify(Justify::Center),
-                        input_count.to_string().cell().justify(Justify::Right),
-                        output_count.to_string().cell().justify(Justify::Right),
-                        total_value.to_string().cell().justify(Justify::Right),
-                    ]);
-                }
-                let table = rows
-                    .table()
-                    .title(vec![
-                        "Txid".cell().bold(true),
-                        "Version".cell().bold(true),
-                        "Is RBF".cell().bold(true),
-                        "Input Count".cell().bold(true),
-                        "Output Count".cell().bold(true),
-                        "Total Value (sat)".cell().bold(true),
-                    ])
-                    .display()
-                    .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                let txns: Vec<_> = transactions
-                    .map(|tx| {
-                        json!({
-                            "txid": tx.tx_node.txid,
-                            "is_coinbase": tx.tx_node.is_coinbase(),
-                            "wtxid": tx.tx_node.compute_wtxid(),
-                            "version": tx.tx_node.version,
-                            "is_rbf": tx.tx_node.is_explicitly_rbf(),
-                            "inputs": tx.tx_node.input,
-                            "outputs": tx.tx_node.output,
-                        })
-                    })
-                    .collect();
-                Ok(serde_json::to_string_pretty(&txns)?)
-            }
+            let txns: Vec<TransactionDetails> = transactions
+                .map(|tx| {
+                    let total_value = tx
+                        .tx_node
+                        .output
+                        .iter()
+                        .map(|output| output.value.to_sat())
+                        .sum::<u64>();
+
+                    TransactionDetails {
+                        txid: tx.tx_node.txid.to_string(),
+                        is_coinbase: tx.tx_node.is_coinbase(),
+                        wtxid: tx.tx_node.compute_wtxid().to_string(),
+                        version: serde_json::to_value(tx.tx_node.version).unwrap_or(json!(1)),
+                        version_display: tx.tx_node.version.to_string(),
+                        is_rbf: tx.tx_node.is_explicitly_rbf(),
+                        inputs: serde_json::to_value(&tx.tx_node.input).unwrap_or_default(),
+                        outputs: serde_json::to_value(&tx.tx_node.output).unwrap_or_default(),
+                        input_count: tx.tx_node.input.len(),
+                        output_count: tx.tx_node.output.len(),
+                        total_value,
+                    }
+                })
+                .collect();
+
+            let result = TransactionListResult(txns);
+            result.format(pretty)
         }
         Balance => {
             let balance = wallet.balance();
-            if cli_opts.pretty {
-                let table = vec![
-                    vec!["Type".cell().bold(true), "Amount (sat)".cell().bold(true)],
-                    vec![
-                        "Total".cell(),
-                        balance
-                            .total()
-                            .to_sat()
-                            .to_string()
-                            .cell()
-                            .justify(Justify::Right),
-                    ],
-                    vec![
-                        "Confirmed".cell(),
-                        balance
-                            .confirmed
-                            .to_sat()
-                            .to_string()
-                            .cell()
-                            .justify(Justify::Right),
-                    ],
-                    vec![
-                        "Unconfirmed".cell(),
-                        balance
-                            .immature
-                            .to_sat()
-                            .to_string()
-                            .cell()
-                            .justify(Justify::Right),
-                    ],
-                    vec![
-                        "Trusted Pending".cell(),
-                        balance
-                            .trusted_pending
-                            .to_sat()
-                            .cell()
-                            .justify(Justify::Right),
-                    ],
-                    vec![
-                        "Untrusted Pending".cell(),
-                        balance
-                            .untrusted_pending
-                            .to_sat()
-                            .cell()
-                            .justify(Justify::Right),
-                    ],
-                ]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"satoshi": wallet.balance()}),
-                )?)
-            }
+            let result: BalanceResult = balance.into();
+            result.format(pretty)
         }
 
         CreateTx {
@@ -319,17 +150,8 @@ pub fn handle_offline_wallet_subcommand(
 
             let psbt = tx_builder.finish()?;
 
-            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
-
-            if wallet_opts.verbose {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"psbt": psbt_base64, "details": psbt}),
-                )?)
-            } else {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"psbt": psbt_base64 }),
-                )?)
-            }
+            let result = PsbtResult::with_details(&psbt, wallet_opts.verbose);
+            result.format(pretty)
         }
         BumpFee {
             txid,
@@ -365,62 +187,24 @@ pub fn handle_offline_wallet_subcommand(
 
             let psbt = tx_builder.finish()?;
 
-            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
-
-            Ok(serde_json::to_string_pretty(
-                &json!({"psbt": psbt_base64 }),
-            )?)
+            let result = PsbtResult::with_details(&psbt, wallet_opts.verbose);
+            result.format(pretty)
         }
         Policies => {
             let external_policy = wallet.policies(KeychainKind::External)?;
             let internal_policy = wallet.policies(KeychainKind::Internal)?;
-            if cli_opts.pretty {
-                let table = vec![
-                    vec![
-                        "External".cell().bold(true),
-                        serde_json::to_string_pretty(&external_policy)?.cell(),
-                    ],
-                    vec![
-                        "Internal".cell().bold(true),
-                        serde_json::to_string_pretty(&internal_policy)?.cell(),
-                    ],
-                ]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "external": external_policy,
-                    "internal": internal_policy,
-                }))?)
-            }
+            let result = PoliciesResult {
+                external: serde_json::to_value(&external_policy).unwrap_or(json!(null)),
+                internal: serde_json::to_value(&internal_policy).unwrap_or(json!(null)),
+            };
+            result.format(pretty)
         }
         PublicDescriptor => {
-            let external = wallet.public_descriptor(KeychainKind::External).to_string();
-            let internal = wallet.public_descriptor(KeychainKind::Internal).to_string();
-
-            if cli_opts.pretty {
-                let table = vec![
-                    vec![
-                        "External Descriptor".cell().bold(true),
-                        external.to_string().cell(),
-                    ],
-                    vec![
-                        "Internal Descriptor".cell().bold(true),
-                        internal.to_string().cell(),
-                    ],
-                ]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                Ok(serde_json::to_string_pretty(&json!({
-                    "external": external.to_string(),
-                    "internal": internal.to_string(),
-                }))?)
-            }
+            let result = PublicDescriptorResult {
+                external: wallet.public_descriptor(KeychainKind::External).to_string(),
+                internal: wallet.public_descriptor(KeychainKind::Internal).to_string(),
+            };
+            result.format(pretty)
         }
         Sign {
             psbt,
@@ -435,35 +219,16 @@ pub fn handle_offline_wallet_subcommand(
                 ..Default::default()
             };
             let finalized = wallet.sign(&mut psbt, signopt)?;
-            let psbt_base64 = BASE64_STANDARD.encode(psbt.serialize());
-            if wallet_opts.verbose {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"psbt": &psbt_base64, "is_finalized": finalized, "serialized_psbt": &psbt}),
-                )?)
-            } else {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"psbt": &psbt_base64, "is_finalized": finalized}),
-                )?)
-            }
+
+            let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose);
+            result.format(pretty)
         }
         ExtractPsbt { psbt } => {
             let psbt_serialized = BASE64_STANDARD.decode(psbt)?;
             let psbt = Psbt::deserialize(&psbt_serialized)?;
             let raw_tx = psbt.extract_tx()?;
-            if cli_opts.pretty {
-                let table = vec![vec![
-                    "Raw Transaction".cell().bold(true),
-                    serialize_hex(&raw_tx).cell(),
-                ]]
-                .table()
-                .display()
-                .map_err(|e| Error::Generic(e.to_string()))?;
-                Ok(format!("{table}"))
-            } else {
-                Ok(serde_json::to_string_pretty(
-                    &json!({"raw_tx": serialize_hex(&raw_tx)}),
-                )?)
-            }
+            let result = RawPsbt::new(&raw_tx);
+            result.format(pretty)
         }
         FinalizePsbt {
             psbt,
@@ -479,15 +244,9 @@ pub fn handle_offline_wallet_subcommand(
                 ..Default::default()
             };
             let finalized = wallet.finalize_psbt(&mut psbt, signopt)?;
-            if wallet_opts.verbose {
-                Ok(serde_json::to_string_pretty(
-                    &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized, "details": psbt}),
-                )?)
-            } else {
-                Ok(serde_json::to_string_pretty(
-                    &json!({ "psbt": BASE64_STANDARD.encode(psbt.serialize()), "is_finalized": finalized}),
-                )?)
-            }
+
+            let result = PsbtResult::with_status_and_details(&psbt, finalized, wallet_opts.verbose);
+            result.format(pretty)
         }
         CombinePsbt { psbt } => {
             let mut psbts = psbt
@@ -508,9 +267,8 @@ pub fn handle_offline_wallet_subcommand(
                     Ok(acc)
                 },
             )?;
-            Ok(serde_json::to_string_pretty(
-                &json!({ "psbt": BASE64_STANDARD.encode(final_psbt.serialize()) }),
-            )?)
+            let result = PsbtResult::new(&final_psbt);
+            result.format(pretty)
         }
     }
 }
diff --git a/src/handlers/types.rs b/src/handlers/types.rs
new file mode 100644 (file)
index 0000000..cd25313
--- /dev/null
@@ -0,0 +1,411 @@
+use crate::utils::output::FormatOutput;
+use crate::{error::BDKCliError as Error, utils::shorten};
+use bdk_wallet::bitcoin::base64::Engine;
+use bdk_wallet::bitcoin::base64::prelude::BASE64_STANDARD;
+use bdk_wallet::bitcoin::consensus::encode::serialize_hex;
+use bdk_wallet::bitcoin::{Address, Network, Psbt, Transaction};
+use bdk_wallet::chain::ChainPosition;
+use bdk_wallet::{AddressInfo, Balance, LocalOutput};
+use cli_table::{Cell, CellStruct, Style, Table, format::Justify};
+use serde::Serialize;
+use serde_json::json;
+
+/// Represent address result
+#[derive(Serialize)]
+pub struct AddressResult {
+    pub address: String,
+    pub index: u32,
+}
+
+impl From<AddressInfo> for AddressResult {
+    fn from(info: AddressInfo) -> Self {
+        Self {
+            address: info.address.to_string(),
+            index: info.index,
+        }
+    }
+}
+
+/// pretty presentation for address
+impl FormatOutput for AddressResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let table = vec![
+            vec!["Address".cell().bold(true), self.address.clone().cell()],
+            vec!["Index".cell().bold(true), self.index.cell()],
+        ]
+        .table()
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+/// Represents the data for a single transaction
+#[derive(Serialize)]
+pub struct TransactionDetails {
+    pub txid: String,
+    pub is_coinbase: bool,
+    pub wtxid: String,
+    pub version: serde_json::Value,
+    pub is_rbf: bool,
+    pub inputs: serde_json::Value,
+    pub outputs: serde_json::Value,
+    #[serde(skip)]
+    pub version_display: String,
+    #[serde(skip)]
+    pub input_count: usize,
+    #[serde(skip)]
+    pub output_count: usize,
+    #[serde(skip)]
+    pub total_value: u64,
+}
+
+/// A wrapper type for a list of transactions.
+#[derive(Serialize)]
+#[serde(transparent)]
+pub struct TransactionListResult(pub Vec<TransactionDetails>);
+
+impl FormatOutput for TransactionListResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let mut rows: Vec<Vec<CellStruct>> = vec![];
+
+        for tx in &self.0 {
+            rows.push(vec![
+                tx.txid.clone().cell(),
+                tx.version_display.clone().cell().justify(Justify::Right),
+                tx.is_rbf.to_string().cell().justify(Justify::Center),
+                tx.input_count.to_string().cell().justify(Justify::Right),
+                tx.output_count.to_string().cell().justify(Justify::Right),
+                tx.total_value.to_string().cell().justify(Justify::Right),
+            ]);
+        }
+
+        let table = rows
+            .table()
+            .title(vec![
+                "Txid".cell().bold(true),
+                "Version".cell().bold(true),
+                "Is RBF".cell().bold(true),
+                "Input Count".cell().bold(true),
+                "Output Count".cell().bold(true),
+                "Total Value (sat)".cell().bold(true),
+            ])
+            .display()
+            .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+/// Balance representation
+#[derive(Serialize)]
+pub struct BalanceResult {
+    pub total: u64,
+    pub trusted_pending: u64,
+    pub untrusted_pending: u64,
+    pub immature: u64,
+    pub confirmed: u64,
+}
+
+impl From<Balance> for BalanceResult {
+    fn from(b: Balance) -> Self {
+        Self {
+            total: b.total().to_sat(),
+            confirmed: b.confirmed.to_sat(),
+            trusted_pending: b.trusted_pending.to_sat(),
+            untrusted_pending: b.untrusted_pending.to_sat(),
+            immature: b.immature.to_sat(),
+        }
+    }
+}
+
+impl FormatOutput for BalanceResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let table = vec![
+            vec![
+                "Total".cell().bold(true),
+                self.total.cell().justify(Justify::Right),
+            ],
+            vec![
+                "Confirmed".cell().bold(true),
+                self.confirmed.cell().justify(Justify::Right),
+            ],
+            vec![
+                "Trusted Pending".cell().bold(true),
+                self.trusted_pending.cell().justify(Justify::Right),
+            ],
+            vec![
+                "Untrusted Pending".cell().bold(true),
+                self.untrusted_pending.cell().justify(Justify::Right),
+            ],
+            vec![
+                "Immature".cell().bold(true),
+                self.immature.cell().justify(Justify::Right),
+            ],
+        ]
+        .table()
+        .title(vec![
+            "Status".cell().bold(true),
+            "Amount (sat)".cell().bold(true),
+        ])
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+/// single UTXO
+#[derive(Serialize)]
+pub struct UnspentDetails {
+    pub outpoint: String,
+    pub txout: serde_json::Value,
+    pub keychain: String,
+    pub is_spent: bool,
+    pub derivation_index: u32,
+    pub chain_position: serde_json::Value,
+
+    #[serde(skip)]
+    pub value_sat: u64,
+    #[serde(skip)]
+    pub address: String,
+    #[serde(skip)]
+    pub outpoint_display: String,
+    #[serde(skip)]
+    pub height_display: String,
+    #[serde(skip)]
+    pub block_hash_display: String,
+}
+
+impl UnspentDetails {
+    pub fn from_local_output(utxo: &LocalOutput, network: Network) -> Self {
+        let height = utxo.chain_position.confirmation_height_upper_bound();
+        let height_display = height
+            .map(|h| h.to_string())
+            .unwrap_or_else(|| "Pending".to_string());
+
+        let (_, block_hash_display) = match &utxo.chain_position {
+            ChainPosition::Confirmed { anchor, .. } => {
+                let hash = anchor.block_id.hash.to_string();
+                (Some(hash.clone()), shorten(&hash, 8, 8))
+            }
+            ChainPosition::Unconfirmed { .. } => (None, "Unconfirmed".to_string()),
+        };
+
+        let address = Address::from_script(&utxo.txout.script_pubkey, network)
+            .map(|a| a.to_string())
+            .unwrap_or_else(|_| "Unknown Script".to_string());
+
+        let outpoint_str = utxo.outpoint.to_string();
+
+        Self {
+            outpoint: outpoint_str.clone(),
+            txout: serde_json::to_value(&utxo.txout).unwrap_or(json!({})),
+            keychain: format!("{:?}", utxo.keychain),
+            is_spent: utxo.is_spent,
+            derivation_index: utxo.derivation_index,
+            chain_position: serde_json::to_value(utxo.chain_position).unwrap_or(json!({})),
+
+            value_sat: utxo.txout.value.to_sat(),
+            address,
+            outpoint_display: shorten(&outpoint_str, 8, 10),
+            height_display,
+            block_hash_display,
+        }
+    }
+}
+
+/// Wrapper for the list of UTXOs
+#[derive(Serialize)]
+#[serde(transparent)]
+pub struct UnspentListResult(pub Vec<UnspentDetails>);
+
+impl FormatOutput for UnspentListResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let mut rows: Vec<Vec<CellStruct>> = vec![];
+
+        for utxo in &self.0 {
+            rows.push(vec![
+                utxo.outpoint_display.clone().cell(),
+                utxo.value_sat.to_string().cell().justify(Justify::Right),
+                utxo.address.clone().cell(),
+                utxo.keychain.clone().cell(),
+                utxo.is_spent.cell(),
+                utxo.derivation_index.cell(),
+                utxo.height_display.clone().cell().justify(Justify::Right),
+                utxo.block_hash_display
+                    .clone()
+                    .cell()
+                    .justify(Justify::Right),
+            ]);
+        }
+
+        let table = rows
+            .table()
+            .title(vec![
+                "Outpoint".cell().bold(true),
+                "Output (sat)".cell().bold(true),
+                "Output Address".cell().bold(true),
+                "Keychain".cell().bold(true),
+                "Is Spent".cell().bold(true),
+                "Index".cell().bold(true),
+                "Block Height".cell().bold(true),
+                "Block Hash".cell().bold(true),
+            ])
+            .display()
+            .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+#[derive(Serialize)]
+pub struct PsbtResult {
+    pub psbt: String,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub is_finalized: Option<bool>,
+
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub details: Option<serde_json::Value>,
+}
+
+impl PsbtResult {
+    pub fn new(psbt: &Psbt) -> Self {
+        Self {
+            psbt: BASE64_STANDARD.encode(psbt.serialize()),
+            is_finalized: None,
+            details: None,
+        }
+    }
+
+    pub fn with_details(psbt: &Psbt, verbose: bool) -> Self {
+        Self {
+            psbt: BASE64_STANDARD.encode(psbt.serialize()),
+            is_finalized: None,
+            details: if verbose {
+                Some(serde_json::to_value(psbt).unwrap_or(json!({})))
+            } else {
+                None
+            },
+        }
+    }
+
+    pub fn with_status_and_details(psbt: &Psbt, is_finalized: bool, verbose: bool) -> Self {
+        Self {
+            psbt: BASE64_STANDARD.encode(psbt.serialize()),
+            is_finalized: Some(is_finalized),
+            details: if verbose {
+                Some(serde_json::to_value(psbt).unwrap_or(json!({})))
+            } else {
+                None
+            },
+        }
+    }
+}
+
+impl FormatOutput for PsbtResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let mut rows = vec![vec![
+            "PSBT (Base64)".cell().bold(true),
+            self.psbt.clone().cell(),
+        ]];
+
+        if let Some(finalized) = self.is_finalized {
+            rows.push(vec!["Is Finalized".cell().bold(true), finalized.cell()]);
+        }
+
+        if self.details.is_some() {
+            rows.push(vec![
+                "Details".cell().bold(true),
+                "Run without --pretty to view verbose JSON details".cell(),
+            ]);
+        }
+
+        let table = rows
+            .table()
+            .display()
+            .map_err(|e| Error::Generic(e.to_string()))?;
+        Ok(format!("{table}"))
+    }
+}
+
+/// Policies representation
+#[derive(Serialize)]
+pub struct PoliciesResult {
+    pub external: serde_json::Value,
+    pub internal: serde_json::Value,
+}
+
+impl FormatOutput for PoliciesResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let ext_str = serde_json::to_string_pretty(&self.external)
+            .map_err(|e| Error::Generic(e.to_string()))?;
+        let int_str = serde_json::to_string_pretty(&self.internal)
+            .map_err(|e| Error::Generic(e.to_string()))?;
+
+        let table = vec![
+            vec!["External".cell().bold(true), ext_str.cell()],
+            vec!["Internal".cell().bold(true), int_str.cell()],
+        ]
+        .table()
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+#[derive(Serialize)]
+pub struct PublicDescriptorResult {
+    pub external: String,
+    pub internal: String,
+}
+
+impl FormatOutput for PublicDescriptorResult {
+    fn to_table(&self) -> Result<String, Error> {
+        let table = vec![
+            vec![
+                "External Descriptor".cell().bold(true),
+                self.external.clone().cell(),
+            ],
+            vec![
+                "Internal Descriptor".cell().bold(true),
+                self.internal.clone().cell(),
+            ],
+        ]
+        .table()
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
+
+#[derive(Serialize)]
+pub struct RawPsbt {
+    pub raw_tx: String,
+}
+
+impl RawPsbt {
+    pub fn new(tx: &Transaction) -> Self {
+        Self {
+            raw_tx: serialize_hex(tx),
+        }
+    }
+}
+
+impl FormatOutput for RawPsbt {
+    fn to_table(&self) -> Result<String, Error> {
+        let table = vec![vec![
+            "Raw Transaction".cell().bold(true),
+            self.raw_tx.clone().cell(),
+        ]]
+        .table()
+        .display()
+        .map_err(|e| Error::Generic(e.to_string()))?;
+
+        Ok(format!("{table}"))
+    }
+}
index 0e827e411c7a0b68a68e8e63be5f0ec77a20bc82..cea89351fea13a7dde06597713ca7cda6b669517 100644 (file)
@@ -1,4 +1,4 @@
 pub mod common;
 pub mod descriptors;
-
+pub mod output;
 pub use common::*;
diff --git a/src/utils/output.rs b/src/utils/output.rs
new file mode 100644 (file)
index 0000000..96b8d9e
--- /dev/null
@@ -0,0 +1,17 @@
+use crate::error::BDKCliError as Error;
+use serde::Serialize;
+
+/// A trait for data structures that can be rendered to the CLI.
+pub trait FormatOutput: Serialize {
+    /// Implement this to define how the data looks as a CLI table.
+    fn to_table(&self) -> Result<String, Error>;
+
+    /// Formats the output based on the user's `--pretty` flag.
+    fn format(&self, pretty: bool) -> Result<String, Error> {
+        if pretty {
+            self.to_table()
+        } else {
+            serde_json::to_string_pretty(self).map_err(|e| Error::Generic(e.to_string()))
+        }
+    }
+}