use crate::db::Database; use chrono::NaiveDate; use csv::Writer; use std::io::Write; #[derive(Debug)] pub enum ExportError { Db(rusqlite::Error), Csv(csv::Error), } impl From for ExportError { fn from(e: rusqlite::Error) -> Self { ExportError::Db(e) } } impl From for ExportError { fn from(e: csv::Error) -> Self { ExportError::Csv(e) } } impl std::fmt::Display for ExportError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ExportError::Db(e) => write!(f, "Database error: {}", e), ExportError::Csv(e) => write!(f, "CSV error: {}", e), } } } pub fn export_transactions_csv( db: &Database, writer: W, from: Option, to: Option, ) -> Result { let transactions = db.list_all_transactions(from, to)?; let mut wtr = Writer::from_writer(writer); wtr.write_record(["Date", "Type", "Category", "Amount", "Currency", "Exchange Rate", "Note", "Payee"])?; for txn in &transactions { let cat_name = db .get_category(txn.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); wtr.write_record(&[ txn.date.format("%Y-%m-%d").to_string(), txn.transaction_type.as_str().to_string(), cat_name, format!("{:.2}", txn.amount), txn.currency.clone(), format!("{:.4}", txn.exchange_rate), txn.note.clone().unwrap_or_default(), txn.payee.clone().unwrap_or_default(), ])?; } wtr.flush().map_err(|e| ExportError::Csv(e.into()))?; Ok(transactions.len()) } #[cfg(test)] mod tests { use super::*; use crate::models::{NewTransaction, TransactionType}; fn setup_db() -> Database { Database::open_in_memory().unwrap() } #[test] fn test_csv_export_format() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat = &cats[0]; let txn = NewTransaction { amount: 42.50, transaction_type: TransactionType::Expense, category_id: cat.id, currency: "USD".to_string(), exchange_rate: 1.0, note: Some("Lunch".to_string()), date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), recurring_id: None, payee: None, }; db.insert_transaction(&txn).unwrap(); let mut buf = Vec::new(); let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); assert_eq!(count, 1); let output = String::from_utf8(buf).unwrap(); let lines: Vec<&str> = output.trim().lines().collect(); assert_eq!(lines.len(), 2); assert_eq!(lines[0], "Date,Type,Category,Amount,Currency,Exchange Rate,Note,Payee"); assert!(lines[1].contains("2026-03-01")); assert!(lines[1].contains("expense")); assert!(lines[1].contains("42.50")); assert!(lines[1].contains("Lunch")); } #[test] fn test_csv_export_empty() { let db = setup_db(); let mut buf = Vec::new(); let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); assert_eq!(count, 0); let output = String::from_utf8(buf).unwrap(); let lines: Vec<&str> = output.trim().lines().collect(); assert_eq!(lines.len(), 1); // header only } #[test] fn test_csv_export_filtered_by_date() { let db = setup_db(); let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let cat_id = cats[0].id; for day in 1..=5 { let txn = NewTransaction { amount: 10.0 * day as f64, transaction_type: TransactionType::Expense, category_id: cat_id, currency: "USD".to_string(), exchange_rate: 1.0, note: None, date: NaiveDate::from_ymd_opt(2026, 1, day).unwrap(), recurring_id: None, payee: None, }; db.insert_transaction(&txn).unwrap(); } // Filter from Jan 2 to Jan 4 let mut buf = Vec::new(); let count = export_transactions_csv( &db, &mut buf, Some(NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()), Some(NaiveDate::from_ymd_opt(2026, 1, 4).unwrap()), ) .unwrap(); assert_eq!(count, 3); } #[test] fn test_csv_export_multiple_types() { let db = setup_db(); let expense_cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); let income_cats = db.list_categories(Some(TransactionType::Income)).unwrap(); let txn1 = NewTransaction { amount: 50.0, transaction_type: TransactionType::Expense, category_id: expense_cats[0].id, currency: "USD".to_string(), exchange_rate: 1.0, note: None, date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), recurring_id: None, payee: None, }; let txn2 = NewTransaction { amount: 1000.0, transaction_type: TransactionType::Income, category_id: income_cats[0].id, currency: "EUR".to_string(), exchange_rate: 0.92, note: Some("Salary".to_string()), date: NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), recurring_id: None, payee: None, }; db.insert_transaction(&txn1).unwrap(); db.insert_transaction(&txn2).unwrap(); let mut buf = Vec::new(); let count = export_transactions_csv(&db, &mut buf, None, None).unwrap(); assert_eq!(count, 2); let output = String::from_utf8(buf).unwrap(); assert!(output.contains("expense")); assert!(output.contains("income")); assert!(output.contains("EUR")); assert!(output.contains("0.9200")); } }