From 8518bb33f535117bc04b59e0286fbd464a17147c Mon Sep 17 00:00:00 2001 From: Jacob Kiers Date: Fri, 28 Nov 2025 19:01:49 +0100 Subject: [PATCH] feat: Introduce configuration structs Instead of relying on the environment variables everywhere, this is now abstracted to a config struct. Furthermore, all tests now also use this struct and are made fully independent of each other. That is because now they don't rely on the set_env() / get_env() calls any more, which are not thread safe. --- banks2ff/src/adapters/firefly/client.rs | 15 +- banks2ff/src/adapters/gocardless/client.rs | 24 +- .../adapters/gocardless/transaction_cache.rs | 198 +++++++------ banks2ff/src/cli/setup.rs | 34 +-- banks2ff/src/core/cache.rs | 61 ++-- banks2ff/src/core/config.rs | 232 +++++++++++++++ banks2ff/src/core/encryption.rs | 81 +++--- banks2ff/src/core/linking.rs | 23 +- banks2ff/src/core/mod.rs | 1 + banks2ff/src/core/sync.rs | 53 +++- banks2ff/src/main.rs | 270 ++++++++++-------- 11 files changed, 685 insertions(+), 307 deletions(-) create mode 100644 banks2ff/src/core/config.rs diff --git a/banks2ff/src/adapters/firefly/client.rs b/banks2ff/src/adapters/firefly/client.rs index 593c165..c847681 100644 --- a/banks2ff/src/adapters/firefly/client.rs +++ b/banks2ff/src/adapters/firefly/client.rs @@ -1,3 +1,4 @@ +use crate::core::config::Config; use crate::core::models::{Account, BankTransaction}; use crate::core::ports::{TransactionDestination, TransactionMatch}; use anyhow::Result; @@ -15,12 +16,14 @@ use tracing::instrument; pub struct FireflyAdapter { client: Arc>, + config: Config, } impl FireflyAdapter { - pub fn new(client: FireflyClient) -> Self { + pub fn new(client: FireflyClient, config: Config) -> Self { Self { client: Arc::new(Mutex::new(client)), + config, } } } @@ -173,7 +176,9 @@ impl TransactionDestination for FireflyAdapter { let mut result = Vec::new(); // Cache the accounts - let mut cache = crate::core::cache::AccountCache::load(); + let encryption = crate::core::encryption::Encryption::new(self.config.cache.key.clone()); + let mut cache = + crate::core::cache::AccountCache::load(self.config.cache.directory.clone(), encryption); for acc in accounts.data { let is_active = acc.attributes.active.unwrap_or(true); @@ -221,9 +226,9 @@ impl TransactionDestination for FireflyAdapter { zoom_level: acc.attributes.zoom_level, last_activity: acc.attributes.last_activity.clone(), }; - cache.insert(crate::core::cache::CachedAccount::Firefly( - Box::new(ff_account), - )); + cache.insert(crate::core::cache::CachedAccount::Firefly(Box::new( + ff_account, + ))); cache.save(); result.push(Account { diff --git a/banks2ff/src/adapters/gocardless/client.rs b/banks2ff/src/adapters/gocardless/client.rs index 70c9c17..fd571c8 100644 --- a/banks2ff/src/adapters/gocardless/client.rs +++ b/banks2ff/src/adapters/gocardless/client.rs @@ -1,6 +1,8 @@ -use crate::core::cache::{AccountCache, CachedAccount, GoCardlessAccount}; use crate::adapters::gocardless::mapper::map_transaction; use crate::adapters::gocardless::transaction_cache::AccountTransactionCache; +use crate::core::cache::{AccountCache, CachedAccount, GoCardlessAccount}; +use crate::core::config::Config; +use crate::core::encryption::Encryption; use crate::core::models::{ Account, AccountStatus, AccountSummary, BankTransaction, CacheInfo, TransactionInfo, }; @@ -19,14 +21,20 @@ pub struct GoCardlessAdapter { client: Arc>, cache: Arc>, transaction_caches: Arc>>, + config: Config, } impl GoCardlessAdapter { - pub fn new(client: GoCardlessClient) -> Self { + pub fn new(client: GoCardlessClient, config: Config) -> Self { + let encryption = Encryption::new(config.cache.key.clone()); Self { client: Arc::new(Mutex::new(client)), - cache: Arc::new(Mutex::new(AccountCache::load())), + cache: Arc::new(Mutex::new(AccountCache::load( + config.cache.directory.clone(), + encryption, + ))), transaction_caches: Arc::new(Mutex::new(HashMap::new())), + config, } } } @@ -189,10 +197,12 @@ impl TransactionSource for GoCardlessAdapter { // Load or get transaction cache let mut caches = self.transaction_caches.lock().await; let cache = caches.entry(account_id.to_string()).or_insert_with(|| { - AccountTransactionCache::load(account_id).unwrap_or_else(|_| AccountTransactionCache { - account_id: account_id.to_string(), - ranges: Vec::new(), - }) + let encryption = Encryption::new(self.config.cache.key.clone()); + let cache_dir = self.config.cache.directory.clone(); + AccountTransactionCache::load(account_id, cache_dir.clone(), encryption.clone()) + .unwrap_or_else(|_| { + AccountTransactionCache::new(account_id.to_string(), cache_dir, encryption) + }) }); // Get cached transactions diff --git a/banks2ff/src/adapters/gocardless/transaction_cache.rs b/banks2ff/src/adapters/gocardless/transaction_cache.rs index 59c5ac4..8d62007 100644 --- a/banks2ff/src/adapters/gocardless/transaction_cache.rs +++ b/banks2ff/src/adapters/gocardless/transaction_cache.rs @@ -5,10 +5,12 @@ use gocardless_client::models::Transaction; use serde::{Deserialize, Serialize}; use std::path::Path; -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Debug, Clone)] pub struct AccountTransactionCache { pub account_id: String, pub ranges: Vec, + cache_dir: String, + encryption: Encryption, } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -18,45 +20,65 @@ pub struct CachedRange { pub transactions: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AccountTransactionCacheData { + pub account_id: String, + pub ranges: Vec, +} + impl AccountTransactionCache { + /// Create new cache with directory and encryption + pub fn new(account_id: String, cache_dir: String, encryption: Encryption) -> Self { + Self { + account_id, + cache_dir, + encryption, + ranges: Vec::new(), + } + } + /// Get cache file path for an account - fn get_cache_path(account_id: &str) -> String { - let cache_dir = - std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); - format!("{}/transactions/{}.enc", cache_dir, account_id) + fn get_cache_path(&self, account_id: &str) -> String { + format!("{}/transactions/{}.enc", self.cache_dir, account_id) } /// Load cache from disk - pub fn load(account_id: &str) -> Result { - let path = Self::get_cache_path(account_id); + pub fn load(account_id: &str, cache_dir: String, encryption: Encryption) -> Result { + let path = format!("{}/transactions/{}.enc", cache_dir, account_id); if !Path::new(&path).exists() { // Return empty cache if file doesn't exist - return Ok(Self { - account_id: account_id.to_string(), - ranges: Vec::new(), - }); + return Ok(Self::new(account_id.to_string(), cache_dir, encryption)); } // Read encrypted data let encrypted_data = std::fs::read(&path)?; - let json_data = Encryption::decrypt(&encrypted_data)?; + let json_data = encryption.decrypt(&encrypted_data)?; // Deserialize - let cache: Self = serde_json::from_slice(&json_data)?; - Ok(cache) + let cache_data: AccountTransactionCacheData = serde_json::from_slice(&json_data)?; + Ok(Self { + account_id: cache_data.account_id, + ranges: cache_data.ranges, + cache_dir, + encryption, + }) } /// Save cache to disk pub fn save(&self) -> Result<()> { - // Serialize to JSON - let json_data = serde_json::to_vec(self)?; + // Serialize to JSON (only the data fields) + let cache_data = AccountTransactionCacheData { + account_id: self.account_id.clone(), + ranges: self.ranges.clone(), + }; + let json_data = serde_json::to_vec(&cache_data)?; // Encrypt - let encrypted_data = Encryption::encrypt(&json_data)?; + let encrypted_data = self.encryption.encrypt(&json_data)?; // Write to file (create directory if needed) - let path = Self::get_cache_path(&self.account_id); + let path = self.get_cache_path(&self.account_id); if let Some(parent) = std::path::Path::new(&path).parent() { std::fs::create_dir_all(parent)?; } @@ -261,10 +283,12 @@ impl AccountTransactionCache { mod tests { use super::*; use chrono::NaiveDate; - use std::env; - fn setup_test_env(test_name: &str) -> String { - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); + fn create_unique_key(prefix: &str) -> String { + format!("{}-{}", prefix, rand::random::()) + } + + fn setup_test_dir(test_name: &str) -> String { // Use a unique cache directory for each test to avoid interference // Include random component and timestamp for true parallelism safety let random_suffix = rand::random::(); @@ -272,12 +296,10 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); - let cache_dir = format!( + format!( "tmp/test-cache-{}-{}-{}", test_name, random_suffix, timestamp - ); - env::set_var("BANKS2FF_CACHE_DIR", cache_dir.clone()); - cache_dir + ) } fn cleanup_test_dir(cache_dir: &str) { @@ -299,8 +321,10 @@ mod tests { #[test] fn test_load_nonexistent_cache() { - let cache_dir = setup_test_env("nonexistent"); - let cache = AccountTransactionCache::load("nonexistent").unwrap(); + let cache_dir = setup_test_dir("nonexistent"); + let encryption = Encryption::new(create_unique_key("test-key")); + let cache = + AccountTransactionCache::load("nonexistent", cache_dir.clone(), encryption).unwrap(); assert_eq!(cache.account_id, "nonexistent"); assert!(cache.ranges.is_empty()); cleanup_test_dir(&cache_dir); @@ -308,25 +332,24 @@ mod tests { #[test] fn test_save_and_load_empty_cache() { - let cache_dir = setup_test_env("empty"); + let cache_dir = setup_test_dir("empty"); + let encryption_key = create_unique_key("test-key"); + let encryption = Encryption::new(encryption_key.clone()); - let cache = AccountTransactionCache { - account_id: "test_account_empty".to_string(), - ranges: Vec::new(), - }; + let cache = AccountTransactionCache::new( + "test_account_empty".to_string(), + cache_dir.clone(), + encryption, + ); - // Ensure env vars are set before save - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); - // Ensure env vars are set before save - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); // Save cache.save().expect("Save should succeed"); - // Ensure env vars are set before load - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); // Load + let encryption = Encryption::new(encryption_key); let loaded = - AccountTransactionCache::load("test_account_empty").expect("Load should succeed"); + AccountTransactionCache::load("test_account_empty", cache_dir.clone(), encryption) + .expect("Load should succeed"); assert_eq!(loaded.account_id, "test_account_empty"); assert!(loaded.ranges.is_empty()); @@ -336,7 +359,8 @@ mod tests { #[test] fn test_save_and_load_with_data() { - let cache_dir = setup_test_env("data"); + let cache_dir = setup_test_dir("data"); + let encryption_key = create_unique_key("test-key"); let transaction = Transaction { transaction_id: Some("test-tx-1".to_string()), @@ -377,21 +401,22 @@ mod tests { transactions: vec![transaction], }; - let cache = AccountTransactionCache { - account_id: "test_account_data".to_string(), - ranges: vec![range], - }; + let encryption = Encryption::new(encryption_key.clone()); + let mut cache = AccountTransactionCache::new( + "test_account_data".to_string(), + cache_dir.clone(), + encryption, + ); + cache.ranges = vec![range]; - // Ensure env vars are set before save - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); // Save cache.save().expect("Save should succeed"); - // Ensure env vars are set before load - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); // Load + let encryption = Encryption::new(encryption_key); let loaded = - AccountTransactionCache::load("test_account_data").expect("Load should succeed"); + AccountTransactionCache::load("test_account_data", cache_dir.clone(), encryption) + .expect("Load should succeed"); assert_eq!(loaded.account_id, "test_account_data"); assert_eq!(loaded.ranges.len(), 1); @@ -406,32 +431,32 @@ mod tests { #[test] fn test_save_load_different_accounts() { - let cache_dir = setup_test_env("different_accounts"); + let cache_dir = setup_test_dir("different_accounts"); + let encryption_key_a = create_unique_key("test-key-a"); + let encryption_key_b = create_unique_key("test-key-b"); // Save cache for account A - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); - let cache_a = AccountTransactionCache { - account_id: "account_a".to_string(), - ranges: Vec::new(), - }; + let encryption_a = Encryption::new(encryption_key_a.clone()); + let cache_a = + AccountTransactionCache::new("account_a".to_string(), cache_dir.clone(), encryption_a); cache_a.save().unwrap(); // Save cache for account B - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); - let cache_b = AccountTransactionCache { - account_id: "account_b".to_string(), - ranges: Vec::new(), - }; + let encryption_b = Encryption::new(encryption_key_b.clone()); + let cache_b = + AccountTransactionCache::new("account_b".to_string(), cache_dir.clone(), encryption_b); cache_b.save().unwrap(); // Load account A - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); - let loaded_a = AccountTransactionCache::load("account_a").unwrap(); + let encryption_a = Encryption::new(encryption_key_a); + let loaded_a = + AccountTransactionCache::load("account_a", cache_dir.clone(), encryption_a).unwrap(); assert_eq!(loaded_a.account_id, "account_a"); // Load account B - env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); - let loaded_b = AccountTransactionCache::load("account_b").unwrap(); + let encryption_b = Encryption::new(encryption_key_b); + let loaded_b = + AccountTransactionCache::load("account_b", cache_dir.clone(), encryption_b).unwrap(); assert_eq!(loaded_b.account_id, "account_b"); cleanup_test_dir(&cache_dir); @@ -439,10 +464,9 @@ mod tests { #[test] fn test_get_uncovered_ranges_no_cache() { - let cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: Vec::new(), - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); let uncovered = cache.get_uncovered_ranges(start, end); @@ -456,10 +480,10 @@ mod tests { end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(), transactions: Vec::new(), }; - let cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: vec![range], - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let mut cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); + cache.ranges = vec![range]; let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); let uncovered = cache.get_uncovered_ranges(start, end); @@ -473,10 +497,10 @@ mod tests { end_date: NaiveDate::from_ymd_opt(2024, 1, 20).unwrap(), transactions: Vec::new(), }; - let cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: vec![range], - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let mut cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); + cache.ranges = vec![range]; let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(); let uncovered = cache.get_uncovered_ranges(start, end); @@ -499,10 +523,9 @@ mod tests { #[test] fn test_store_transactions_and_merge() { - let mut cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: Vec::new(), - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let mut cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); let start1 = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let tx1 = Transaction { @@ -590,10 +613,9 @@ mod tests { #[test] fn test_transaction_deduplication() { - let mut cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: Vec::new(), - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let mut cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let tx1 = Transaction { @@ -673,10 +695,10 @@ mod tests { end_date: NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(), transactions: vec![tx1], }; - let cache = AccountTransactionCache { - account_id: "test".to_string(), - ranges: vec![range], - }; + let encryption = Encryption::new(create_unique_key("test-key")); + let mut cache = + AccountTransactionCache::new("test".to_string(), "test_cache".to_string(), encryption); + cache.ranges = vec![range]; let start = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(); let end = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let cached = cache.get_cached_transactions(start, end); diff --git a/banks2ff/src/cli/setup.rs b/banks2ff/src/cli/setup.rs index 5e79695..1f6ea45 100644 --- a/banks2ff/src/cli/setup.rs +++ b/banks2ff/src/cli/setup.rs @@ -1,12 +1,12 @@ use crate::adapters::firefly::client::FireflyAdapter; use crate::adapters::gocardless::client::GoCardlessAdapter; +use crate::core::config::Config; use crate::debug::DebugLogger; use anyhow::Result; use firefly_client::client::FireflyClient; use gocardless_client::client::GoCardlessClient; use reqwest_middleware::ClientBuilder; -use std::env; pub struct AppContext { pub source: GoCardlessAdapter, @@ -14,38 +14,38 @@ pub struct AppContext { } impl AppContext { - pub async fn new(debug: bool) -> Result { - // Config Load - let gc_url = env::var("GOCARDLESS_URL") - .unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string()); - let gc_id = env::var("GOCARDLESS_ID").expect("GOCARDLESS_ID not set"); - let gc_key = env::var("GOCARDLESS_KEY").expect("GOCARDLESS_KEY not set"); - - let ff_url = env::var("FIREFLY_III_URL").expect("FIREFLY_III_URL not set"); - let ff_key = env::var("FIREFLY_III_API_KEY").expect("FIREFLY_III_API_KEY not set"); - + pub async fn new(config: Config, debug: bool) -> Result { // Clients let gc_client = if debug { let client = ClientBuilder::new(reqwest::Client::new()) .with(DebugLogger::new("gocardless")) .build(); - GoCardlessClient::with_client(&gc_url, &gc_id, &gc_key, Some(client))? + GoCardlessClient::with_client( + &config.gocardless.url, + &config.gocardless.secret_id, + &config.gocardless.secret_key, + Some(client), + )? } else { - GoCardlessClient::new(&gc_url, &gc_id, &gc_key)? + GoCardlessClient::new( + &config.gocardless.url, + &config.gocardless.secret_id, + &config.gocardless.secret_key, + )? }; let ff_client = if debug { let client = ClientBuilder::new(reqwest::Client::new()) .with(DebugLogger::new("firefly")) .build(); - FireflyClient::with_client(&ff_url, &ff_key, Some(client))? + FireflyClient::with_client(&config.firefly.url, &config.firefly.api_key, Some(client))? } else { - FireflyClient::new(&ff_url, &ff_key)? + FireflyClient::new(&config.firefly.url, &config.firefly.api_key)? }; // Adapters - let source = GoCardlessAdapter::new(gc_client); - let destination = FireflyAdapter::new(ff_client); + let source = GoCardlessAdapter::new(gc_client, config.clone()); + let destination = FireflyAdapter::new(ff_client, config); Ok(Self { source, diff --git a/banks2ff/src/core/cache.rs b/banks2ff/src/core/cache.rs index 9286f38..6bb0aa8 100644 --- a/banks2ff/src/core/cache.rs +++ b/banks2ff/src/core/cache.rs @@ -6,13 +6,13 @@ use std::fs; use std::path::Path; use tracing::warn; -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub enum CachedAccount { GoCardless(Box), Firefly(Box), } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct GoCardlessAccount { pub id: String, pub iban: Option, @@ -27,7 +27,7 @@ pub struct GoCardlessAccount { pub cash_account_type: Option, // From AccountDetail.cashAccountType } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct FireflyAccount { pub id: String, pub name: String, // From Account.name @@ -167,26 +167,49 @@ impl AccountData for FireflyAccount { } } -#[derive(Debug, Serialize, Deserialize, Default)] +#[derive(Debug, Default)] pub struct AccountCache { /// Map of Account ID -> Full Account Data pub accounts: HashMap, + /// Cache directory path + cache_dir: String, + /// Encryption instance + encryption: Encryption, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +pub struct AccountCacheData { + /// Map of Account ID -> Full Account Data + pub accounts: HashMap, } impl AccountCache { - fn get_path() -> String { - let cache_dir = - std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); - format!("{}/accounts.enc", cache_dir) + /// Create new AccountCache with directory and encryption + pub fn new(cache_dir: String, encryption: Encryption) -> Self { + Self { + accounts: HashMap::new(), + cache_dir, + encryption, + } } - pub fn load() -> Self { - let path = Self::get_path(); + fn get_path(&self) -> String { + format!("{}/accounts.enc", self.cache_dir) + } + + pub fn load(cache_dir: String, encryption: Encryption) -> Self { + let path = format!("{}/accounts.enc", cache_dir); if Path::new(&path).exists() { match fs::read(&path) { - Ok(encrypted_data) => match Encryption::decrypt(&encrypted_data) { - Ok(json_data) => match serde_json::from_slice(&json_data) { - Ok(cache) => return cache, + Ok(encrypted_data) => match encryption.decrypt(&encrypted_data) { + Ok(json_data) => match serde_json::from_slice::(&json_data) { + Ok(cache_data) => { + return Self { + accounts: cache_data.accounts, + cache_dir, + encryption, + } + } Err(e) => warn!("Failed to parse cache file: {}", e), }, Err(e) => warn!("Failed to decrypt cache file: {}", e), @@ -194,11 +217,11 @@ impl AccountCache { Err(e) => warn!("Failed to read cache file: {}", e), } } - Self::default() + Self::new(cache_dir, encryption) } pub fn save(&self) { - let path = Self::get_path(); + let path = self.get_path(); if let Some(parent) = std::path::Path::new(&path).parent() { if let Err(e) = std::fs::create_dir_all(parent) { @@ -210,8 +233,10 @@ impl AccountCache { } } - match serde_json::to_vec(self) { - Ok(json_data) => match Encryption::encrypt(&json_data) { + match serde_json::to_vec(&AccountCacheData { + accounts: self.accounts.clone(), + }) { + Ok(json_data) => match self.encryption.encrypt(&json_data) { Ok(encrypted_data) => { if let Err(e) = fs::write(&path, encrypted_data) { warn!("Failed to write cache file: {}", e); @@ -242,4 +267,4 @@ impl AccountCache { let account_id = account.id().to_string(); self.accounts.insert(account_id, account); } -} \ No newline at end of file +} diff --git a/banks2ff/src/core/config.rs b/banks2ff/src/core/config.rs new file mode 100644 index 0000000..c772c93 --- /dev/null +++ b/banks2ff/src/core/config.rs @@ -0,0 +1,232 @@ +//! Configuration management for banks2ff +//! +//! Provides centralized configuration loading from environment variables +//! with type-safe configuration structures. + +use anyhow::{anyhow, Result}; +use std::env; + +/// Main application configuration +#[derive(Debug, Clone)] +pub struct Config { + pub gocardless: GoCardlessConfig, + pub firefly: FireflyConfig, + pub cache: CacheConfig, + pub logging: LoggingConfig, +} + +/// GoCardless API configuration +#[derive(Debug, Clone)] +pub struct GoCardlessConfig { + pub url: String, + pub secret_id: String, + pub secret_key: String, +} + +/// Firefly III API configuration +#[derive(Debug, Clone)] +pub struct FireflyConfig { + pub url: String, + pub api_key: String, +} + +/// Cache configuration +#[derive(Debug, Clone)] +pub struct CacheConfig { + pub key: String, + pub directory: String, +} + +/// Logging configuration +#[derive(Debug, Clone)] +pub struct LoggingConfig { + pub level: String, +} + +impl Config { + /// Load configuration from environment variables + pub fn from_env() -> Result { + let gocardless = GoCardlessConfig::from_env()?; + let firefly = FireflyConfig::from_env()?; + let cache = CacheConfig::from_env()?; + let logging = LoggingConfig::from_env()?; + + Ok(Self { + gocardless, + firefly, + cache, + logging, + }) + } +} + +impl GoCardlessConfig { + pub fn from_env() -> Result { + let url = env::var("GOCARDLESS_URL") + .unwrap_or_else(|_| "https://bankaccountdata.gocardless.com".to_string()); + let secret_id = env::var("GOCARDLESS_ID") + .map_err(|_| anyhow!("GOCARDLESS_ID environment variable not set"))?; + let secret_key = env::var("GOCARDLESS_KEY") + .map_err(|_| anyhow!("GOCARDLESS_KEY environment variable not set"))?; + + Ok(Self { + url, + secret_id, + secret_key, + }) + } +} + +impl FireflyConfig { + pub fn from_env() -> Result { + let url = env::var("FIREFLY_III_URL") + .map_err(|_| anyhow!("FIREFLY_III_URL environment variable not set"))?; + let api_key = env::var("FIREFLY_III_API_KEY") + .map_err(|_| anyhow!("FIREFLY_III_API_KEY environment variable not set"))?; + + Ok(Self { url, api_key }) + } +} + +impl CacheConfig { + pub fn from_env() -> Result { + let key = env::var("BANKS2FF_CACHE_KEY") + .map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set"))?; + let directory = env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); + + Ok(Self { key, directory }) + } +} + +impl LoggingConfig { + pub fn from_env() -> Result { + let level = env::var("RUST_LOG").unwrap_or_else(|_| "warn".to_string()); + Ok(Self { level }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + + #[test] + fn test_gocardless_config_from_env() { + env::set_var("GOCARDLESS_ID", "test-id"); + env::set_var("GOCARDLESS_KEY", "test-key"); + env::set_var("GOCARDLESS_URL", "https://test.example.com"); + + let config = GoCardlessConfig::from_env().unwrap(); + assert_eq!(config.secret_id, "test-id"); + assert_eq!(config.secret_key, "test-key"); + assert_eq!(config.url, "https://test.example.com"); + + env::remove_var("GOCARDLESS_ID"); + env::remove_var("GOCARDLESS_KEY"); + env::remove_var("GOCARDLESS_URL"); + } + + #[test] + fn test_gocardless_config_default_url() { + env::set_var("GOCARDLESS_ID", "test-id"); + env::set_var("GOCARDLESS_KEY", "test-key"); + env::remove_var("GOCARDLESS_URL"); + + let config = GoCardlessConfig::from_env().unwrap(); + assert_eq!(config.url, "https://bankaccountdata.gocardless.com"); + + env::remove_var("GOCARDLESS_ID"); + env::remove_var("GOCARDLESS_KEY"); + } + + #[test] + fn test_gocardless_config_missing_id() { + env::remove_var("GOCARDLESS_ID"); + env::set_var("GOCARDLESS_KEY", "test-key"); + + let result = GoCardlessConfig::from_env(); + assert!(result.is_err()); + + env::remove_var("GOCARDLESS_KEY"); + } + + #[test] + fn test_firefly_config_from_env() { + env::set_var("FIREFLY_III_URL", "https://firefly.test.com"); + env::set_var("FIREFLY_III_API_KEY", "test-api-key"); + + let config = FireflyConfig::from_env().unwrap(); + assert_eq!(config.url, "https://firefly.test.com"); + assert_eq!(config.api_key, "test-api-key"); + + env::remove_var("FIREFLY_III_URL"); + env::remove_var("FIREFLY_III_API_KEY"); + } + + #[test] + fn test_cache_config_from_env() { + env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); + env::set_var("BANKS2FF_CACHE_DIR", "/tmp/test-cache"); + + let config = CacheConfig::from_env().unwrap(); + assert_eq!(config.key, "test-cache-key"); + assert_eq!(config.directory, "/tmp/test-cache"); + + env::remove_var("BANKS2FF_CACHE_KEY"); + env::remove_var("BANKS2FF_CACHE_DIR"); + } + + #[test] + fn test_cache_config_default_directory() { + env::remove_var("BANKS2FF_CACHE_DIR"); + env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); + + let config = CacheConfig::from_env().unwrap(); + assert_eq!(config.directory, "data/cache"); + + env::remove_var("BANKS2FF_CACHE_KEY"); + } + + #[test] + fn test_logging_config_from_env() { + env::set_var("RUST_LOG", "debug"); + + let config = LoggingConfig::from_env().unwrap(); + assert_eq!(config.level, "debug"); + + env::remove_var("RUST_LOG"); + } + + #[test] + fn test_logging_config_default() { + env::remove_var("RUST_LOG"); + + let config = LoggingConfig::from_env().unwrap(); + assert_eq!(config.level, "warn"); + } + + #[test] + fn test_full_config_from_env() { + env::set_var("GOCARDLESS_ID", "test-id"); + env::set_var("GOCARDLESS_KEY", "test-key"); + env::set_var("FIREFLY_III_URL", "https://firefly.test.com"); + env::set_var("FIREFLY_III_API_KEY", "test-api-key"); + env::set_var("BANKS2FF_CACHE_KEY", "test-cache-key"); + env::set_var("RUST_LOG", "info"); + + let config = Config::from_env().unwrap(); + assert_eq!(config.gocardless.secret_id, "test-id"); + assert_eq!(config.gocardless.secret_key, "test-key"); + assert_eq!(config.firefly.url, "https://firefly.test.com"); + assert_eq!(config.firefly.api_key, "test-api-key"); + assert_eq!(config.cache.key, "test-cache-key"); + assert_eq!(config.logging.level, "info"); + + env::remove_var("GOCARDLESS_ID"); + env::remove_var("GOCARDLESS_KEY"); + env::remove_var("FIREFLY_III_URL"); + env::remove_var("FIREFLY_III_API_KEY"); + env::remove_var("BANKS2FF_CACHE_KEY"); + env::remove_var("RUST_LOG"); + } +} diff --git a/banks2ff/src/core/encryption.rs b/banks2ff/src/core/encryption.rs index ca795bb..c914d17 100644 --- a/banks2ff/src/core/encryption.rs +++ b/banks2ff/src/core/encryption.rs @@ -33,31 +33,39 @@ use anyhow::{anyhow, Result}; use pbkdf2::pbkdf2_hmac; use rand::RngCore; use sha2::Sha256; -use std::env; const KEY_LEN: usize = 32; // 256-bit key const NONCE_LEN: usize = 12; // 96-bit nonce for AES-GCM const SALT_LEN: usize = 16; // 128-bit salt for PBKDF2 -pub struct Encryption; +#[derive(Debug, Clone, Default)] +pub struct Encryption { + password: String, +} impl Encryption { - /// Derive encryption key from environment variable and salt + /// Create new Encryption instance with cache key + pub fn new(cache_key: String) -> Self { + Self { + password: cache_key, + } + } + + /// Derive encryption key from password and salt pub fn derive_key(password: &str, salt: &[u8]) -> Key { let mut key = [0u8; KEY_LEN]; pbkdf2_hmac::(password.as_bytes(), salt, 200_000, &mut key); key.into() } - /// Get password from environment variable - fn get_password() -> Result { - env::var("BANKS2FF_CACHE_KEY") - .map_err(|_| anyhow!("BANKS2FF_CACHE_KEY environment variable not set")) + /// Get password from instance + fn get_password(&self) -> Result { + Ok(self.password.clone()) } /// Encrypt data using AES-GCM - pub fn encrypt(data: &[u8]) -> Result> { - let password = Self::get_password()?; + pub fn encrypt(&self, data: &[u8]) -> Result> { + let password = self.get_password()?; // Generate random salt let mut salt = [0u8; SALT_LEN]; @@ -84,13 +92,13 @@ impl Encryption { } /// Decrypt data using AES-GCM - pub fn decrypt(encrypted_data: &[u8]) -> Result> { + pub fn decrypt(&self, encrypted_data: &[u8]) -> Result> { let min_len = SALT_LEN + NONCE_LEN; if encrypted_data.len() < min_len { return Err(anyhow!("Encrypted data too short")); } - let password = Self::get_password()?; + let password = self.get_password()?; // Extract salt, nonce and ciphertext: [salt(16)][nonce(12)][ciphertext] let salt = &encrypted_data[..SALT_LEN]; @@ -110,23 +118,21 @@ impl Encryption { #[cfg(test)] mod tests { use super::*; - use std::env; #[test] fn test_encrypt_decrypt_round_trip() { - // Set test environment variable - env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption"); - + let encryption = Encryption::new("test-key-for-encryption".to_string()); let original_data = b"Hello, World! This is test data."; // Encrypt - let encrypted = Encryption::encrypt(original_data).expect("Encryption should succeed"); - - // Ensure env var is still set for decryption - env::set_var("BANKS2FF_CACHE_KEY", "test-key-for-encryption"); + let encrypted = encryption + .encrypt(original_data) + .expect("Encryption should succeed"); // Decrypt - let decrypted = Encryption::decrypt(&encrypted).expect("Decryption should succeed"); + let decrypted = encryption + .decrypt(&encrypted) + .expect("Decryption should succeed"); // Verify assert_eq!(original_data.to_vec(), decrypted); @@ -135,41 +141,28 @@ mod tests { #[test] fn test_encrypt_decrypt_different_keys() { - env::set_var("BANKS2FF_CACHE_KEY", "key1"); + let encryption1 = Encryption::new("key1".to_string()); + let encryption2 = Encryption::new("key2".to_string()); let data = b"Test data"; - let encrypted = Encryption::encrypt(data).unwrap(); + let encrypted = encryption1.encrypt(data).unwrap(); - env::set_var("BANKS2FF_CACHE_KEY", "key2"); - let result = Encryption::decrypt(&encrypted); + let result = encryption2.decrypt(&encrypted); assert!(result.is_err(), "Should fail with different key"); } #[test] - fn test_missing_env_var() { - // Save current value and restore after test - let original_value = env::var("BANKS2FF_CACHE_KEY").ok(); - env::remove_var("BANKS2FF_CACHE_KEY"); - - let result = Encryption::get_password(); - assert!(result.is_err(), "Should fail without env var"); - - // Restore original value - if let Some(val) = original_value { - env::set_var("BANKS2FF_CACHE_KEY", val); - } + fn test_encryption_creation() { + let encryption = Encryption::new("test-key".to_string()); + assert_eq!(encryption.password, "test-key"); } #[test] fn test_small_data() { - // Set env var multiple times to ensure it's available - env::set_var("BANKS2FF_CACHE_KEY", "test-key"); + let encryption = Encryption::new("test-key".to_string()); let data = b"{}"; // Minimal JSON object - env::set_var("BANKS2FF_CACHE_KEY", "test-key"); - let encrypted = Encryption::encrypt(data).unwrap(); - - env::set_var("BANKS2FF_CACHE_KEY", "test-key"); - let decrypted = Encryption::decrypt(&encrypted).unwrap(); + let encrypted = encryption.encrypt(data).unwrap(); + let decrypted = encryption.decrypt(&encrypted).unwrap(); assert_eq!(data.to_vec(), decrypted); } -} \ No newline at end of file +} diff --git a/banks2ff/src/core/linking.rs b/banks2ff/src/core/linking.rs index fd23c1d..e1fd3ec 100644 --- a/banks2ff/src/core/linking.rs +++ b/banks2ff/src/core/linking.rs @@ -17,17 +17,24 @@ pub struct AccountLink { #[derive(Debug, Serialize, Deserialize, Default)] pub struct LinkStore { pub links: Vec, + cache_dir: String, } impl LinkStore { - fn get_path() -> String { - let cache_dir = - std::env::var("BANKS2FF_CACHE_DIR").unwrap_or_else(|_| "data/cache".to_string()); - format!("{}/links.json", cache_dir) + /// Create new LinkStore with cache directory + pub fn new(cache_dir: String) -> Self { + Self { + links: Vec::new(), + cache_dir, + } } - pub fn load() -> Self { - let path = Self::get_path(); + fn get_path(&self) -> String { + format!("{}/links.json", self.cache_dir) + } + + pub fn load(cache_dir: String) -> Self { + let path = format!("{}/links.json", cache_dir); if Path::new(&path).exists() { match fs::read_to_string(&path) { Ok(content) => match serde_json::from_str(&content) { @@ -37,11 +44,11 @@ impl LinkStore { Err(e) => warn!("Failed to read link store: {}", e), } } - Self::default() + Self::new(cache_dir) } pub fn save(&self) -> Result<()> { - let path = Self::get_path(); + let path = self.get_path(); if let Some(parent) = std::path::Path::new(&path).parent() { std::fs::create_dir_all(parent)?; } diff --git a/banks2ff/src/core/mod.rs b/banks2ff/src/core/mod.rs index 4129e75..e35a246 100644 --- a/banks2ff/src/core/mod.rs +++ b/banks2ff/src/core/mod.rs @@ -1,5 +1,6 @@ pub mod adapters; pub mod cache; +pub mod config; pub mod encryption; pub mod linking; pub mod models; diff --git a/banks2ff/src/core/sync.rs b/banks2ff/src/core/sync.rs index e0a3449..3f307dd 100644 --- a/banks2ff/src/core/sync.rs +++ b/banks2ff/src/core/sync.rs @@ -1,3 +1,4 @@ +use crate::core::config::Config; use crate::core::linking::{auto_link_accounts, LinkStore}; use crate::core::models::{Account, SyncError}; use crate::core::ports::{IngestResult, TransactionDestination, TransactionSource}; @@ -17,6 +18,7 @@ pub struct SyncResult { pub async fn run_sync( source: impl TransactionSource, destination: impl TransactionDestination, + config: Config, cli_start_date: Option, cli_end_date: Option, dry_run: bool, @@ -40,7 +42,7 @@ pub async fn run_sync( // Accounts are cached by their respective adapters during discover_accounts - let mut link_store = LinkStore::load(); + let mut link_store = LinkStore::load(config.cache.directory.clone()); // Auto-link accounts based on IBAN let links = auto_link_accounts(&all_source_accounts, &all_dest_accounts); @@ -272,11 +274,39 @@ async fn process_single_account( #[cfg(test)] mod tests { use super::*; + use crate::core::config::{ + CacheConfig, Config, FireflyConfig, GoCardlessConfig, LoggingConfig, + }; use crate::core::models::{Account, BankTransaction}; use crate::core::ports::{MockTransactionDestination, MockTransactionSource, TransactionMatch}; use mockall::predicate::*; use rust_decimal::Decimal; + fn create_unique_key(prefix: &str) -> String { + format!("{}-{}", prefix, rand::random::()) + } + + fn create_test_config(temp_dir: &str) -> Config { + Config { + gocardless: GoCardlessConfig { + url: "https://bankaccountdata.gocardless.com".to_string(), + secret_id: create_unique_key("gocardless-id"), + secret_key: create_unique_key("gocardless-key"), + }, + firefly: FireflyConfig { + url: "https://firefly.test.com".to_string(), + api_key: create_unique_key("firefly-api-key"), + }, + cache: CacheConfig { + key: create_unique_key("cache-key"), + directory: temp_dir.to_string(), + }, + logging: LoggingConfig { + level: "warn".to_string(), + }, + } + } + #[tokio::test] async fn test_sync_flow_create_new() { let mut source = MockTransactionSource::new(); @@ -346,8 +376,13 @@ mod tests { .returning(|_, _| Ok(())); // Execution - let res = run_sync(&source, &dest, None, None, false).await; + let temp_dir = format!("tmp/test-sync-{}", rand::random::()); + let config = create_test_config(&temp_dir); + let res = run_sync(&source, &dest, config, None, None, false).await; assert!(res.is_ok()); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); } #[tokio::test] @@ -413,8 +448,13 @@ mod tests { .times(1) .returning(|_, _| Ok(())); - let res = run_sync(&source, &dest, None, None, false).await; + let temp_dir = format!("tmp/test-sync-heal-{}", rand::random::()); + let config = create_test_config(&temp_dir); + let res = run_sync(&source, &dest, config, None, None, false).await; assert!(res.is_ok()); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); } #[tokio::test] @@ -475,7 +515,12 @@ mod tests { dest.expect_create_transaction().never(); dest.expect_update_transaction_external_id().never(); - let res = run_sync(source, dest, None, None, true).await; + let temp_dir = format!("tmp/test-sync-dry-run-{}", rand::random::()); + let config = create_test_config(&temp_dir); + let res = run_sync(source, dest, config, None, None, true).await; assert!(res.is_ok()); + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); } } diff --git a/banks2ff/src/main.rs b/banks2ff/src/main.rs index 697cd94..d4f3c77 100644 --- a/banks2ff/src/main.rs +++ b/banks2ff/src/main.rs @@ -8,6 +8,8 @@ use crate::cli::setup::AppContext; use crate::core::adapters::{ get_available_destinations, get_available_sources, is_valid_destination, is_valid_source, }; +use crate::core::config::Config; +use crate::core::encryption::Encryption; use crate::core::linking::LinkStore; use crate::core::models::AccountData; use crate::core::ports::TransactionSource; @@ -125,6 +127,9 @@ async fn main() -> anyhow::Result<()> { // Load environment variables first dotenvy::dotenv().ok(); + // Load configuration + let config = Config::from_env()?; + let args = Args::parse(); // Initialize logging based on command type @@ -135,12 +140,11 @@ async fn main() -> anyhow::Result<()> { _ => "warn", }; - let log_level = std::env::var("RUST_LOG") - .map(|s| { - s.parse() - .unwrap_or(tracing_subscriber::EnvFilter::new(default_level)) - }) - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(default_level)); + let log_level = config + .logging + .level + .parse() + .unwrap_or(tracing_subscriber::EnvFilter::new(default_level)); tracing_subscriber::fmt().with_env_filter(log_level).init(); @@ -156,7 +160,16 @@ async fn main() -> anyhow::Result<()> { start, end, } => { - handle_sync(args.debug, source, destination, start, end, args.dry_run).await?; + handle_sync( + config, + args.debug, + source, + destination, + start, + end, + args.dry_run, + ) + .await?; } Commands::Sources => { @@ -167,11 +180,11 @@ async fn main() -> anyhow::Result<()> { } Commands::Accounts { subcommand } => { - handle_accounts(subcommand).await?; + handle_accounts(config, subcommand).await?; } Commands::Transactions { subcommand } => { - handle_transactions(subcommand).await?; + handle_transactions(config, subcommand).await?; } } @@ -179,6 +192,7 @@ async fn main() -> anyhow::Result<()> { } async fn handle_sync( + config: Config, debug: bool, source: String, destination: String, @@ -222,10 +236,19 @@ async fn handle_sync( anyhow::bail!("Only 'firefly' destination is currently supported (implementation pending)"); } - let context = AppContext::new(debug).await?; + let context = AppContext::new(config.clone(), debug).await?; // Run sync - match run_sync(context.source, context.destination, start, end, dry_run).await { + match run_sync( + context.source, + context.destination, + config, + start, + end, + dry_run, + ) + .await + { Ok(result) => { info!("Sync completed successfully."); info!( @@ -264,15 +287,127 @@ async fn handle_destinations() -> anyhow::Result<()> { Ok(()) } -async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> { - let context = AppContext::new(false).await?; +async fn handle_accounts(config: Config, subcommand: AccountCommands) -> anyhow::Result<()> { + let context = AppContext::new(config.clone(), false).await?; let format = OutputFormat::Table; // TODO: Add --json flag match subcommand { AccountCommands::Link { subcommand: link_sub, } => { - handle_link(link_sub).await?; + match link_sub { + LinkCommands::List => { + let encryption = Encryption::new(config.cache.key.clone()); + let link_store = LinkStore::load(config.cache.directory.clone()); + let account_cache = crate::core::cache::AccountCache::load( + config.cache.directory.clone(), + encryption, + ); + + if link_store.links.is_empty() { + println!("No account links found."); + } else { + println!("Account Links:"); + for link in &link_store.links { + let source_name = account_cache + .get_display_name(&link.source_account_id) + .unwrap_or_else(|| format!("Account {}", &link.source_account_id)); + let dest_name = account_cache + .get_display_name(&link.dest_account_id) + .unwrap_or_else(|| format!("Account {}", &link.dest_account_id)); + 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, + } => { + let encryption = Encryption::new(config.cache.key.clone()); + let mut link_store = LinkStore::load(config.cache.directory.clone()); + let account_cache = crate::core::cache::AccountCache::load( + config.cache.directory.clone(), + encryption, + ); + + // Assume source_account is gocardless id, dest_account is firefly id + let source_acc = account_cache.get_account(&source_account); + let dest_acc = account_cache.get_account(&dest_account); + + if let (Some(src), Some(dst)) = (source_acc, dest_acc) { + // Create minimal Account structs for linking + let src_minimal = crate::core::models::Account { + id: src.id().to_string(), + name: Some(src.id().to_string()), // Use ID as name for linking + iban: src.iban().map(|s| s.to_string()), + currency: "EUR".to_string(), + }; + let dst_minimal = crate::core::models::Account { + id: dst.id().to_string(), + name: Some(dst.id().to_string()), // Use ID as name for linking + iban: dst.iban().map(|s| s.to_string()), + currency: "EUR".to_string(), + }; + + if let Some(link_id) = + link_store.add_link(&src_minimal, &dst_minimal, false) + { + link_store.save()?; + let src_display = account_cache + .get_display_name(&source_account) + .unwrap_or_else(|| source_account.clone()); + let dst_display = account_cache + .get_display_name(&dest_account) + .unwrap_or_else(|| dest_account.clone()); + println!( + "Created link {} between {} and {}", + link_id, src_display, dst_display + ); + } else { + let src_display = account_cache + .get_display_name(&source_account) + .unwrap_or_else(|| source_account.clone()); + let dst_display = account_cache + .get_display_name(&dest_account) + .unwrap_or_else(|| dest_account.clone()); + println!( + "Link between {} and {} already exists", + src_display, dst_display + ); + } + } else { + println!( + "Account not found. Ensure accounts are discovered via sync first." + ); + } + } + LinkCommands::Delete { link_id } => { + let mut link_store = LinkStore::load(config.cache.directory.clone()); + if link_store.remove_link(&link_id).is_ok() { + link_store.save()?; + println!("Deleted link {}", link_id); + } else { + println!("Link {} not found", link_id); + } + } + LinkCommands::Alias { link_id, alias } => { + let mut link_store = LinkStore::load(config.cache.directory.clone()); + if link_store.set_alias(&link_id, alias.clone()).is_ok() { + link_store.save()?; + println!("Set alias '{}' for link {}", alias, link_id); + } else { + println!("Link {} not found", link_id); + } + } + } } AccountCommands::List => { let accounts = context.source.list_accounts().await?; @@ -294,8 +429,11 @@ async fn handle_accounts(subcommand: AccountCommands) -> anyhow::Result<()> { Ok(()) } -async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result<()> { - let context = AppContext::new(false).await?; +async fn handle_transactions( + config: Config, + subcommand: TransactionCommands, +) -> anyhow::Result<()> { + let context = AppContext::new(config.clone(), false).await?; let format = OutputFormat::Table; // TODO: Add --json flag match subcommand { @@ -322,103 +460,3 @@ async fn handle_transactions(subcommand: TransactionCommands) -> anyhow::Result< } Ok(()) } - -async fn handle_link(subcommand: LinkCommands) -> anyhow::Result<()> { - let mut link_store = LinkStore::load(); - let account_cache = crate::core::cache::AccountCache::load(); - - match subcommand { - LinkCommands::List => { - if link_store.links.is_empty() { - println!("No account links found."); - } else { - println!("Account Links:"); - for link in &link_store.links { - let source_name = account_cache - .get_display_name(&link.source_account_id) - .unwrap_or_else(|| format!("Account {}", &link.source_account_id)); - let dest_name = account_cache - .get_display_name(&link.dest_account_id) - .unwrap_or_else(|| format!("Account {}", &link.dest_account_id)); - 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, - } => { - // Assume source_account is gocardless id, dest_account is firefly id - let source_acc = account_cache.get_account(&source_account); - let dest_acc = account_cache.get_account(&dest_account); - - if let (Some(src), Some(dst)) = (source_acc, dest_acc) { - // Create minimal Account structs for linking - let src_minimal = crate::core::models::Account { - id: src.id().to_string(), - name: Some(src.id().to_string()), // Use ID as name for linking - iban: src.iban().map(|s| s.to_string()), - currency: "EUR".to_string(), - }; - let dst_minimal = crate::core::models::Account { - id: dst.id().to_string(), - name: Some(dst.id().to_string()), // Use ID as name for linking - iban: dst.iban().map(|s| s.to_string()), - currency: "EUR".to_string(), - }; - - if let Some(link_id) = link_store.add_link(&src_minimal, &dst_minimal, false) { - link_store.save()?; - let src_display = account_cache - .get_display_name(&source_account) - .unwrap_or_else(|| source_account.clone()); - let dst_display = account_cache - .get_display_name(&dest_account) - .unwrap_or_else(|| dest_account.clone()); - println!( - "Created link {} between {} and {}", - link_id, src_display, dst_display - ); - } else { - let src_display = account_cache - .get_display_name(&source_account) - .unwrap_or_else(|| source_account.clone()); - let dst_display = account_cache - .get_display_name(&dest_account) - .unwrap_or_else(|| dest_account.clone()); - println!( - "Link between {} and {} already exists", - src_display, dst_display - ); - } - } else { - println!("Account not found. Ensure accounts are discovered via sync first."); - } - } - LinkCommands::Delete { link_id } => { - if link_store.remove_link(&link_id).is_ok() { - link_store.save()?; - println!("Deleted link {}", link_id); - } else { - println!("Link {} not found", link_id); - } - } - LinkCommands::Alias { link_id, alias } => { - if link_store.set_alias(&link_id, alias.clone()).is_ok() { - link_store.save()?; - println!("Set alias '{}' for link {}", alias, link_id); - } else { - println!("Link {} not found", link_id); - } - } - } - Ok(()) -}