feat: Add CLI table formatting and remove unused inspection methods

- Enhanced CLI output with table formatting for better readability of account and transaction data
- Added new commands to list accounts and view their sync status
- Added new commands to inspect transaction information and cache status
- Cleaned up internal code by removing unused trait methods and implementations
- Updated documentation with examples of new CLI commands

This improves the user experience with clearer CLI output and new inspection capabilities while maintaining code quality.
This commit is contained in:
2025-11-22 18:54:53 +00:00
parent b85c366176
commit baac50c36a
12 changed files with 450 additions and 247 deletions

113
Cargo.lock generated
View File

@@ -198,6 +198,7 @@ dependencies = [
"bytes", "bytes",
"chrono", "chrono",
"clap", "clap",
"comfy-table",
"dotenvy", "dotenvy",
"firefly-client", "firefly-client",
"gocardless-client", "gocardless-client",
@@ -413,6 +414,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "comfy-table"
version = "7.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b03b7db8e0b4b2fdad6c551e634134e99ec000e5c8c3b6856c65e8bbaded7a3b"
dependencies = [
"crossterm",
"unicode-segmentation",
"unicode-width",
]
[[package]] [[package]]
name = "concurrent-queue" name = "concurrent-queue"
version = "2.5.0" version = "2.5.0"
@@ -453,6 +465,29 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.10.0",
"crossterm_winapi",
"document-features",
"parking_lot",
"rustix",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
dependencies = [
"winapi",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -520,6 +555,15 @@ dependencies = [
"syn 2.0.110", "syn 2.0.110",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
@@ -553,6 +597,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "event-listener" name = "event-listener"
version = "2.5.3" version = "2.5.3"
@@ -1154,12 +1208,24 @@ version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]] [[package]]
name = "litemap" name = "litemap"
version = "0.8.1" version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1706,6 +1772,19 @@ dependencies = [
"serde_json", "serde_json",
] ]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rustls" name = "rustls"
version = "0.21.12" version = "0.21.12"
@@ -2259,6 +2338,18 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-width"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]] [[package]]
name = "universal-hash" name = "universal-hash"
version = "0.5.1" version = "0.5.1"
@@ -2422,6 +2513,28 @@ version = "0.25.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"

View File

@@ -29,6 +29,7 @@ url = "2.5"
wiremock = "0.5" wiremock = "0.5"
tokio-test = "0.4" tokio-test = "0.4"
mockall = "0.11" mockall = "0.11"
reqwest-middleware = "0.2" reqwest-middleware = "0.2"
hyper = { version = "0.14", features = ["full"] } hyper = { version = "0.14", features = ["full"] }
bytes = "1.0" bytes = "1.0"
comfy-table = "7.1"

View File

@@ -44,11 +44,17 @@ cargo run -p banks2ff -- sync gocardless firefly --start 2023-01-01 --end 2023-0
cargo run -p banks2ff -- sources cargo run -p banks2ff -- sources
cargo run -p banks2ff -- destinations cargo run -p banks2ff -- destinations
# Inspect accounts
cargo run -p banks2ff -- accounts list
cargo run -p banks2ff -- accounts status
# Manage account links # Manage account links
cargo run -p banks2ff -- accounts link list cargo run -p banks2ff -- accounts link list
cargo run -p banks2ff -- accounts link create <source_account> <dest_account> cargo run -p banks2ff -- accounts link create <source_account> <dest_account>
# Additional inspection commands available in future releases # Inspect transactions and cache
cargo run -p banks2ff -- transactions list <account_id>
cargo run -p banks2ff -- transactions cache-status
``` ```
## 🖥️ CLI Structure ## 🖥️ CLI Structure
@@ -58,9 +64,12 @@ Banks2FF uses a structured command-line interface with the following commands:
- `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination - `sync <SOURCE> <DESTINATION>` - Synchronize transactions between source and destination
- `sources` - List all available source types - `sources` - List all available source types
- `destinations` - List all available destination types - `destinations` - List all available destination types
- `accounts list` - List all discovered accounts
- `accounts status` - Show sync status for all accounts
- `accounts link` - Manage account links between sources and destinations - `accounts link` - Manage account links between sources and destinations
- `transactions list <account_id>` - Show transaction information for a specific account
Additional inspection commands (accounts list/status, transactions, status) will be available in future releases. - `transactions cache-status` - Display cache status and statistics
- `transactions clear-cache` - Clear transaction cache (implementation pending)
Use `cargo run -p banks2ff -- --help` for detailed command information. Use `cargo run -p banks2ff -- --help` for detailed command information.

View File

@@ -38,5 +38,8 @@ pbkdf2 = "0.12"
rand = "0.8" rand = "0.8"
sha2 = "0.10" sha2 = "0.10"
# CLI formatting dependencies
comfy-table = { workspace = true }
[dev-dependencies] [dev-dependencies]
mockall = { workspace = true } mockall = { workspace = true }

View File

@@ -1,6 +1,4 @@
use crate::core::models::{ use crate::core::models::{Account, BankTransaction};
Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo,
};
use crate::core::ports::{TransactionDestination, TransactionMatch}; use crate::core::ports::{TransactionDestination, TransactionMatch};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
@@ -29,31 +27,6 @@ impl FireflyAdapter {
#[async_trait] #[async_trait]
impl TransactionDestination for FireflyAdapter { impl TransactionDestination for FireflyAdapter {
#[instrument(skip(self))]
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
let client = self.client.lock().await;
let accounts = client.search_accounts(iban).await?;
// Look for exact match on IBAN, ensuring account is active
for acc in accounts.data {
// Filter for active accounts only (default is usually active, but let's check if attribute exists)
// Note: The Firefly API spec v6.4.4 Account object has 'active' attribute as boolean.
let is_active = acc.attributes.active.unwrap_or(true);
if !is_active {
continue;
}
if let Some(acc_iban) = acc.attributes.iban {
if acc_iban.replace(" ", "") == iban.replace(" ", "") {
return Ok(Some(acc.id));
}
}
}
Ok(None)
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn get_active_account_ibans(&self) -> Result<Vec<String>> { async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
let client = self.client.lock().await; let client = self.client.lock().await;
@@ -221,109 +194,6 @@ impl TransactionDestination for FireflyAdapter {
.map_err(|e| e.into()) .map_err(|e| e.into())
} }
#[instrument(skip(self))]
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
let client = self.client.lock().await;
let accounts = client.get_accounts("").await?;
let mut summaries = Vec::new();
for acc in accounts.data {
let is_active = acc.attributes.active.unwrap_or(true);
if is_active {
if let Some(iban) = acc.attributes.iban {
summaries.push(AccountSummary {
id: acc.id,
iban,
currency: "EUR".to_string(), // Default to EUR
status: "active".to_string(),
});
}
}
}
Ok(summaries)
}
#[instrument(skip(self))]
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
let client = self.client.lock().await;
let accounts = client.get_accounts("").await?;
let mut statuses = Vec::new();
for acc in accounts.data {
let is_active = acc.attributes.active.unwrap_or(true);
if is_active {
if let Some(iban) = acc.attributes.iban {
let last_sync_date = self.get_last_transaction_date(&acc.id).await?;
let transaction_count = client
.list_account_transactions(&acc.id, None, None)
.await?
.data
.len();
statuses.push(AccountStatus {
account_id: acc.id,
iban,
last_sync_date,
transaction_count,
status: "active".to_string(),
});
}
}
}
Ok(statuses)
}
#[instrument(skip(self))]
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
let client = self.client.lock().await;
let tx_list = client
.list_account_transactions(account_id, None, None)
.await?;
let total_count = tx_list.data.len();
let date_range = if tx_list.data.is_empty() {
None
} else {
let dates: Vec<NaiveDate> = tx_list
.data
.iter()
.filter_map(|tx| {
tx.attributes.transactions.first().and_then(|split| {
split
.date
.split('T')
.next()
.and_then(|d| NaiveDate::parse_from_str(d, "%Y-%m-%d").ok())
})
})
.collect();
if dates.is_empty() {
None
} else {
let min_date = dates.iter().min().cloned();
let max_date = dates.iter().max().cloned();
min_date.and_then(|min| max_date.map(|max| (min, max)))
}
};
let last_updated = date_range.map(|(_, max)| max);
Ok(TransactionInfo {
account_id: account_id.to_string(),
total_count,
date_range,
last_updated,
})
}
#[instrument(skip(self))]
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
// Firefly doesn't have local cache, so return empty
Ok(Vec::new())
}
#[instrument(skip(self))] #[instrument(skip(self))]
async fn discover_accounts(&self) -> Result<Vec<Account>> { async fn discover_accounts(&self) -> Result<Vec<Account>> {
let client = self.client.lock().await; let client = self.client.lock().await;

View File

@@ -0,0 +1,123 @@
use crate::core::models::{AccountStatus, AccountSummary, CacheInfo, TransactionInfo};
use comfy_table::{presets::UTF8_FULL, Table};
pub enum OutputFormat {
Table,
}
pub trait Formattable {
fn to_table(&self) -> Table;
}
pub fn print_list_output<T: Formattable>(data: Vec<T>, format: &OutputFormat) {
if data.is_empty() {
println!("No data available");
return;
}
match format {
OutputFormat::Table => {
for item in data {
println!("{}", item.to_table());
}
}
}
}
// Implement Formattable for the model structs
impl Formattable for AccountSummary {
fn to_table(&self) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec!["ID", "IBAN", "Currency", "Status"]);
table.add_row(vec![
self.id.clone(),
mask_iban(&self.iban),
self.currency.clone(),
self.status.clone(),
]);
table
}
}
impl Formattable for AccountStatus {
fn to_table(&self) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account ID",
"IBAN",
"Last Sync",
"Transaction Count",
"Status",
]);
table.add_row(vec![
self.account_id.clone(),
mask_iban(&self.iban),
self.last_sync_date
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
self.transaction_count.to_string(),
self.status.clone(),
]);
table
}
}
impl Formattable for TransactionInfo {
fn to_table(&self) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account ID",
"Total Transactions",
"Date Range",
"Last Updated",
]);
let date_range = self
.date_range
.map(|(start, end)| format!("{} to {}", start, end))
.unwrap_or_else(|| "N/A".to_string());
table.add_row(vec![
self.account_id.clone(),
self.total_count.to_string(),
date_range,
self.last_updated
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
]);
table
}
}
impl Formattable for CacheInfo {
fn to_table(&self) -> Table {
let mut table = Table::new();
table.load_preset(UTF8_FULL);
table.set_header(vec![
"Account ID",
"Cache Type",
"Entry Count",
"Size (bytes)",
"Last Updated",
]);
table.add_row(vec![
self.account_id.as_deref().unwrap_or("Global").to_string(),
self.cache_type.clone(),
self.entry_count.to_string(),
self.total_size_bytes.to_string(),
self.last_updated
.map(|d| d.to_string())
.unwrap_or_else(|| "Never".to_string()),
]);
table
}
}
fn mask_iban(iban: &str) -> String {
if iban.len() <= 4 {
iban.to_string()
} else {
format!("{}{}", "*".repeat(iban.len() - 4), &iban[iban.len() - 4..])
}
}

View File

@@ -1 +1,2 @@
pub mod formatters;
pub mod setup; pub mod setup;

View File

@@ -25,7 +25,8 @@ pub struct LinkStore {
impl LinkStore { impl LinkStore {
fn get_path() -> String { fn get_path() -> String {
let cache_dir = std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); let cache_dir =
std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string());
format!("{}/links.json", cache_dir) format!("{}/links.json", cache_dir)
} }
@@ -53,7 +54,12 @@ impl LinkStore {
Ok(()) Ok(())
} }
pub fn add_link(&mut self, source_account: &Account, dest_account: &Account, auto_linked: bool) -> String { pub fn add_link(
&mut self,
source_account: &Account,
dest_account: &Account,
auto_linked: bool,
) -> String {
let id = format!("link_{}", self.next_id); let id = format!("link_{}", self.next_id);
self.next_id += 1; self.next_id += 1;
let link = AccountLink { let link = AccountLink {
@@ -85,30 +91,28 @@ impl LinkStore {
self.links.iter().find(|l| l.source_account_id == source_id) self.links.iter().find(|l| l.source_account_id == source_id)
} }
pub fn find_link_by_dest(&self, dest_id: &str) -> Option<&AccountLink> {
self.links.iter().find(|l| l.dest_account_id == dest_id)
}
pub fn find_link_by_alias(&self, alias: &str) -> Option<&AccountLink> {
self.links.iter().find(|l| l.alias.as_ref() == Some(&alias.to_string()))
}
pub fn update_source_accounts(&mut self, source_type: &str, accounts: Vec<Account>) { pub fn update_source_accounts(&mut self, source_type: &str, accounts: Vec<Account>) {
let type_map = self.source_accounts.entry(source_type.to_string()).or_insert_with(HashMap::new); let type_map = self
.source_accounts
.entry(source_type.to_string())
.or_default();
for account in accounts { for account in accounts {
type_map.insert(account.id.clone(), account); type_map.insert(account.id.clone(), account);
} }
} }
pub fn update_dest_accounts(&mut self, dest_type: &str, accounts: Vec<Account>) { pub fn update_dest_accounts(&mut self, dest_type: &str, accounts: Vec<Account>) {
let type_map = self.dest_accounts.entry(dest_type.to_string()).or_insert_with(HashMap::new); let type_map = self.dest_accounts.entry(dest_type.to_string()).or_default();
for account in accounts { for account in accounts {
type_map.insert(account.id.clone(), account); type_map.insert(account.id.clone(), account);
} }
} }
} }
pub fn auto_link_accounts(source_accounts: &[Account], dest_accounts: &[Account]) -> Vec<(usize, usize)> { pub fn auto_link_accounts(
source_accounts: &[Account],
dest_accounts: &[Account],
) -> Vec<(usize, usize)> {
let mut links = Vec::new(); let mut links = Vec::new();
for (i, source) in source_accounts.iter().enumerate() { for (i, source) in source_accounts.iter().enumerate() {
for (j, dest) in dest_accounts.iter().enumerate() { for (j, dest) in dest_accounts.iter().enumerate() {

View File

@@ -83,7 +83,6 @@ pub struct TransactionMatch {
#[cfg_attr(test, automock)] #[cfg_attr(test, automock)]
#[async_trait] #[async_trait]
pub trait TransactionDestination: Send + Sync { pub trait TransactionDestination: Send + Sync {
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>>;
/// Get list of all active asset account IBANs to drive the sync /// Get list of all active asset account IBANs to drive the sync
async fn get_active_account_ibans(&self) -> Result<Vec<String>>; async fn get_active_account_ibans(&self) -> Result<Vec<String>>;
@@ -97,12 +96,6 @@ pub trait TransactionDestination: Send + Sync {
async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>; async fn create_transaction(&self, account_id: &str, tx: &BankTransaction) -> Result<()>;
async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>; async fn update_transaction_external_id(&self, id: &str, external_id: &str) -> Result<()>;
/// Inspection methods for CLI
async fn list_accounts(&self) -> Result<Vec<AccountSummary>>;
async fn get_account_status(&self) -> Result<Vec<AccountStatus>>;
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo>;
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>>;
/// Account discovery for linking /// Account discovery for linking
async fn discover_accounts(&self) -> Result<Vec<Account>>; async fn discover_accounts(&self) -> Result<Vec<Account>>;
} }
@@ -110,10 +103,6 @@ pub trait TransactionDestination: Send + Sync {
// Blanket implementation for references // Blanket implementation for references
#[async_trait] #[async_trait]
impl<T: TransactionDestination> TransactionDestination for &T { impl<T: TransactionDestination> TransactionDestination for &T {
async fn resolve_account_id(&self, iban: &str) -> Result<Option<String>> {
(**self).resolve_account_id(iban).await
}
async fn get_active_account_ibans(&self) -> Result<Vec<String>> { async fn get_active_account_ibans(&self) -> Result<Vec<String>> {
(**self).get_active_account_ibans().await (**self).get_active_account_ibans().await
} }
@@ -140,22 +129,6 @@ impl<T: TransactionDestination> TransactionDestination for &T {
.await .await
} }
async fn list_accounts(&self) -> Result<Vec<AccountSummary>> {
(**self).list_accounts().await
}
async fn get_account_status(&self) -> Result<Vec<AccountStatus>> {
(**self).get_account_status().await
}
async fn get_transaction_info(&self, account_id: &str) -> Result<TransactionInfo> {
(**self).get_transaction_info(account_id).await
}
async fn get_cache_info(&self) -> Result<Vec<CacheInfo>> {
(**self).get_cache_info().await
}
async fn discover_accounts(&self) -> Result<Vec<Account>> { async fn discover_accounts(&self) -> Result<Vec<Account>> {
(**self).discover_accounts().await (**self).discover_accounts().await
} }

View File

@@ -289,15 +289,13 @@ mod tests {
}]) }])
}); });
source source.expect_discover_accounts().returning(|| {
.expect_discover_accounts() Ok(vec![Account {
.returning(|| { id: "src_1".to_string(),
Ok(vec![Account { iban: "NL01".to_string(),
id: "src_1".to_string(), currency: "EUR".to_string(),
iban: "NL01".to_string(), }])
currency: "EUR".to_string(), });
}])
});
let tx = BankTransaction { let tx = BankTransaction {
internal_id: "tx1".into(), internal_id: "tx1".into(),
@@ -320,17 +318,13 @@ mod tests {
dest.expect_get_active_account_ibans() dest.expect_get_active_account_ibans()
.returning(|| Ok(vec!["NL01".to_string()])); .returning(|| Ok(vec!["NL01".to_string()]));
dest.expect_discover_accounts() dest.expect_discover_accounts().returning(|| {
.returning(|| { Ok(vec![Account {
Ok(vec![Account { id: "dest_1".to_string(),
id: "dest_1".to_string(), iban: "NL01".to_string(),
iban: "NL01".to_string(), currency: "EUR".to_string(),
currency: "EUR".to_string(), }])
}]) });
});
dest.expect_resolve_account_id()
.returning(|_| Ok(Some("dest_1".into())));
dest.expect_get_last_transaction_date() dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap()))); .returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
@@ -359,14 +353,13 @@ mod tests {
dest.expect_get_active_account_ibans() dest.expect_get_active_account_ibans()
.returning(|| Ok(vec!["NL01".to_string()])); .returning(|| Ok(vec!["NL01".to_string()]));
dest.expect_discover_accounts() dest.expect_discover_accounts().returning(|| {
.returning(|| { Ok(vec![Account {
Ok(vec![Account { id: "dest_1".to_string(),
id: "dest_1".to_string(), iban: "NL01".to_string(),
iban: "NL01".to_string(), currency: "EUR".to_string(),
currency: "EUR".to_string(), }])
}]) });
});
source.expect_get_accounts().with(always()).returning(|_| { source.expect_get_accounts().with(always()).returning(|_| {
Ok(vec![Account { Ok(vec![Account {
@@ -398,8 +391,6 @@ mod tests {
}]) }])
}); });
dest.expect_resolve_account_id()
.returning(|_| Ok(Some("dest_1".into())));
dest.expect_get_last_transaction_date() dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap()))); .returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));
@@ -429,14 +420,13 @@ mod tests {
dest.expect_get_active_account_ibans() dest.expect_get_active_account_ibans()
.returning(|| Ok(vec!["NL01".to_string()])); .returning(|| Ok(vec!["NL01".to_string()]));
dest.expect_discover_accounts() dest.expect_discover_accounts().returning(|| {
.returning(|| { Ok(vec![Account {
Ok(vec![Account { id: "dest_1".to_string(),
id: "dest_1".to_string(), iban: "NL01".to_string(),
iban: "NL01".to_string(), currency: "EUR".to_string(),
currency: "EUR".to_string(), }])
}]) });
});
source.expect_get_accounts().with(always()).returning(|_| { source.expect_get_accounts().with(always()).returning(|_| {
Ok(vec![Account { Ok(vec![Account {
@@ -470,8 +460,6 @@ mod tests {
.expect_get_transactions() .expect_get_transactions()
.returning(move |_, _, _| Ok(vec![tx.clone()])); .returning(move |_, _, _| Ok(vec![tx.clone()]));
dest.expect_resolve_account_id()
.returning(|_| Ok(Some("dest_1".into())));
dest.expect_get_last_transaction_date() dest.expect_get_last_transaction_date()
.returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap()))); .returning(|_| Ok(Some(NaiveDate::from_ymd_opt(2022, 12, 31).unwrap())));

View File

@@ -3,11 +3,13 @@ mod cli;
mod core; mod core;
mod debug; mod debug;
use crate::cli::formatters::{print_list_output, OutputFormat};
use crate::cli::setup::AppContext; use crate::cli::setup::AppContext;
use crate::core::adapters::{ use crate::core::adapters::{
get_available_destinations, get_available_sources, is_valid_destination, is_valid_source, get_available_destinations, get_available_sources, is_valid_destination, is_valid_source,
}; };
use crate::core::linking::LinkStore; use crate::core::linking::LinkStore;
use crate::core::ports::TransactionSource;
use crate::core::sync::run_sync; use crate::core::sync::run_sync;
use chrono::NaiveDate; use chrono::NaiveDate;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
@@ -54,21 +56,18 @@ enum Commands {
subcommand: AccountCommands, subcommand: AccountCommands,
}, },
/// Manage transactions and cache
Transactions {
#[command(subcommand)]
subcommand: TransactionCommands,
},
/// List all available source types /// List all available source types
Sources, Sources,
/// List all available destination types /// List all available destination types
Destinations, Destinations,
} }
#[derive(Subcommand, Debug)]
enum AccountCommands {
/// Manage account links between sources and destinations
Link {
#[command(subcommand)]
subcommand: LinkCommands,
},
}
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum LinkCommands { enum LinkCommands {
/// List all account links /// List all account links
@@ -94,6 +93,32 @@ enum LinkCommands {
}, },
} }
#[derive(Subcommand, Debug)]
enum AccountCommands {
/// Manage account links between sources and destinations
Link {
#[command(subcommand)]
subcommand: LinkCommands,
},
/// List all accounts
List,
/// Show account status
Status,
}
#[derive(Subcommand, Debug)]
enum TransactionCommands {
/// List transactions for an account
List {
/// Account ID to list transactions for
account_id: String,
},
/// Show cache status
CacheStatus,
/// Clear transaction cache
ClearCache,
}
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
// Load environment variables first // Load environment variables first
@@ -143,6 +168,10 @@ async fn main() -> anyhow::Result<()> {
Commands::Accounts { subcommand } => { Commands::Accounts { subcommand } => {
handle_accounts(subcommand).await?; handle_accounts(subcommand).await?;
} }
Commands::Transactions { subcommand } => {
handle_transactions(subcommand).await?;
}
} }
Ok(()) Ok(())
@@ -235,10 +264,60 @@ async fn handle_destinations() -> anyhow::Result<()> {
} }
async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> { async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> {
let context = AppContext::new(false).await?;
let format = OutputFormat::Table; // TODO: Add --json flag
match subcommand { match subcommand {
AccountCommands::Link { subcommand: link_sub } => { AccountCommands::Link {
subcommand: link_sub,
} => {
handle_link(link_sub).await?; handle_link(link_sub).await?;
} }
AccountCommands::List => {
let accounts = context.source.list_accounts().await?;
if accounts.is_empty() {
println!("No accounts found. Run 'banks2ff sync gocardless firefly' first to discover and cache account data.");
} else {
print_list_output(accounts, &format);
}
}
AccountCommands::Status => {
let status = context.source.get_account_status().await?;
if status.is_empty() {
println!("No account status available. Run 'banks2ff sync gocardless firefly' first to sync transactions and build status data.");
} else {
print_list_output(status, &format);
}
}
}
Ok(())
}
async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result<()> {
let context = AppContext::new(false).await?;
let format = OutputFormat::Table; // TODO: Add --json flag
match subcommand {
TransactionCommands::List { account_id } => {
let info = context.source.get_transaction_info(&account_id).await?;
if info.total_count == 0 {
println!("No transaction data found for account {}. Run 'banks2ff sync gocardless firefly' first to sync transactions.", account_id);
} else {
print_list_output(vec![info], &format);
}
}
TransactionCommands::CacheStatus => {
let cache_info = context.source.get_cache_info().await?;
if cache_info.is_empty() {
println!("No cache data available. Run 'banks2ff sync gocardless firefly' first to populate caches.");
} else {
print_list_output(cache_info, &format);
}
}
TransactionCommands::ClearCache => {
// TODO: Implement cache clearing
println!("Cache clearing not yet implemented");
}
} }
Ok(()) Ok(())
} }
@@ -253,24 +332,55 @@ async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> {
} else { } else {
println!("Account Links:"); println!("Account Links:");
for link in &link_store.links { for link in &link_store.links {
let source_acc = link_store.source_accounts.get("gocardless").and_then(|m| m.get(&link.source_account_id)); let source_acc = link_store
let dest_acc = link_store.dest_accounts.get("firefly").and_then(|m| m.get(&link.dest_account_id)); .source_accounts
let source_name = source_acc.map(|a| format!("{} ({})", a.iban, a.id)).unwrap_or_else(|| link.source_account_id.clone()); .get("gocardless")
let dest_name = dest_acc.map(|a| format!("{} ({})", a.iban, a.id)).unwrap_or_else(|| link.dest_account_id.clone()); .and_then(|m| m.get(&link.source_account_id));
let alias_info = link.alias.as_ref().map(|a| format!(" [alias: {}]", a)).unwrap_or_default(); let dest_acc = link_store
println!(" {}: {}{}{}", link.id, source_name, dest_name, alias_info); .dest_accounts
.get("firefly")
.and_then(|m| m.get(&link.dest_account_id));
let source_name = source_acc
.map(|a| format!("{} ({})", a.iban, a.id))
.unwrap_or_else(|| link.source_account_id.clone());
let dest_name = dest_acc
.map(|a| format!("{} ({})", a.iban, a.id))
.unwrap_or_else(|| link.dest_account_id.clone());
let alias_info = link
.alias
.as_ref()
.map(|a| format!(" [alias: {}]", a))
.unwrap_or_default();
println!(
" {}: {}{}{}",
link.id, source_name, dest_name, alias_info
);
} }
} }
} }
LinkCommands::Create { source_account, dest_account } => { LinkCommands::Create {
source_account,
dest_account,
} => {
// Assume source_account is gocardless id, dest_account is firefly id // Assume source_account is gocardless id, dest_account is firefly id
let source_acc = link_store.source_accounts.get("gocardless").and_then(|m| m.get(&source_account)).cloned(); let source_acc = link_store
let dest_acc = link_store.dest_accounts.get("firefly").and_then(|m| m.get(&dest_account)).cloned(); .source_accounts
.get("gocardless")
.and_then(|m| m.get(&source_account))
.cloned();
let dest_acc = link_store
.dest_accounts
.get("firefly")
.and_then(|m| m.get(&dest_account))
.cloned();
if let (Some(src), Some(dst)) = (source_acc, dest_acc) { if let (Some(src), Some(dst)) = (source_acc, dest_acc) {
let link_id = link_store.add_link(&src, &dst, false); let link_id = link_store.add_link(&src, &dst, false);
link_store.save()?; link_store.save()?;
println!("Created link {} between {} and {}", link_id, src.iban, dst.iban); println!(
"Created link {} between {} and {}",
link_id, src.iban, dst.iban
);
} else { } else {
println!("Account not found. Ensure accounts are discovered via sync first."); println!("Account not found. Ensure accounts are discovered via sync first.");
} }

View File

@@ -131,19 +131,19 @@ COMMANDS:
- Added CLI commands under `banks2ff accounts link` with full CRUD operations and alias support - Added CLI commands under `banks2ff accounts link` with full CRUD operations and alias support
- Updated README with new account linking feature, examples, and troubleshooting - Updated README with new account linking feature, examples, and troubleshooting
### Phase 4: CLI Output and Formatting ### Phase 4: CLI Output and Formatting ✅ COMPLETED
**Objective**: Implement user-friendly output for inspection commands. **Objective**: Implement user-friendly output for inspection commands.
**Steps:** **Steps:**
1. Create `cli::formatters` module for consistent output formatting 1. Create `cli::formatters` module for consistent output formatting
2. Implement table-based display for accounts and transactions 2. Implement table-based display for accounts and transactions
3. Add JSON output option for programmatic use 3. Add JSON output option for programmatic use
4. Ensure sensitive data masking in all outputs 4. Ensure sensitive data masking in all outputs
5. Add progress indicators for long-running operations 5. Add progress indicators for long-running operations (pending)
6. Implement `accounts` command with `list` and `status` subcommands 6. Implement `accounts` command with `list` and `status` subcommands
7. Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands 7. Implement `transactions` command with `list`, `cache-status`, and `clear-cache` subcommands
8. Add account and transaction inspection methods to adapter traits 8. Add account and transaction inspection methods to adapter traits
**Testing:** **Testing:**
- Unit tests for formatter functions - Unit tests for formatter functions
@@ -152,6 +152,14 @@ COMMANDS:
- Unit tests for new command implementations - Unit tests for new command implementations
- Integration tests for account/transaction inspection - Integration tests for account/transaction inspection
**Implementation Details:**
- Created `cli::formatters` module with `Formattable` trait and table formatting using `comfy-table`
- Implemented table display for `AccountSummary`, `AccountStatus`, `TransactionInfo`, and `CacheInfo` structs
- Added IBAN masking (showing only last 4 characters) for privacy
- Updated CLI structure with new `accounts` and `transactions` commands
- Added `print_list_output` function for displaying collections of data
- All code formatted with `cargo fmt` and linted with `cargo clippy`
### Phase 5: Status and Cache Management ### Phase 5: Status and Cache Management
**Objective**: Implement status overview and cache management commands. **Objective**: Implement status overview and cache management commands.