diff --git a/outlay-core/src/db.rs b/outlay-core/src/db.rs index 8e88dc2..9282897 100644 --- a/outlay-core/src/db.rs +++ b/outlay-core/src/db.rs @@ -469,6 +469,21 @@ impl Database { rows.collect() } + pub fn list_all_budgets(&self) -> SqlResult> { + let mut stmt = self.conn.prepare( + "SELECT id, category_id, amount, month FROM budgets ORDER BY month ASC, id ASC" + )?; + let rows = stmt.query_map([], |row| { + Ok(Budget { + id: row.get(0)?, + category_id: row.get(1)?, + amount: row.get(2)?, + month: row.get(3)?, + }) + })?; + rows.collect() + } + pub fn delete_budget(&self, id: i64) -> SqlResult<()> { self.conn.execute("DELETE FROM budgets WHERE id = ?1", params![id])?; Ok(()) diff --git a/outlay-core/src/export_json.rs b/outlay-core/src/export_json.rs index e69de29..1360ba9 100644 --- a/outlay-core/src/export_json.rs +++ b/outlay-core/src/export_json.rs @@ -0,0 +1,150 @@ +use crate::db::Database; +use crate::models::{Budget, Category, RecurringTransaction, Transaction}; +use serde::Serialize; +use std::io::Write; + +#[derive(Debug, Serialize)] +pub struct ExportData { + pub transactions: Vec, + pub categories: Vec, + pub budgets: Vec, + pub recurring: Vec, +} + +#[derive(Debug)] +pub enum ExportError { + Db(rusqlite::Error), + Json(serde_json::Error), + Io(std::io::Error), +} + +impl From for ExportError { + fn from(e: rusqlite::Error) -> Self { + ExportError::Db(e) + } +} + +impl From for ExportError { + fn from(e: serde_json::Error) -> Self { + ExportError::Json(e) + } +} + +impl From for ExportError { + fn from(e: std::io::Error) -> Self { + ExportError::Io(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::Json(e) => write!(f, "JSON error: {}", e), + ExportError::Io(e) => write!(f, "IO error: {}", e), + } + } +} + +pub fn export_json(db: &Database, writer: W) -> Result { + let transactions = db.list_all_transactions(None, None)?; + let categories = db.list_categories(None)?; + let budgets = db.list_all_budgets()?; + let recurring = db.list_recurring(false)?; + + let data = ExportData { + transactions, + categories, + budgets, + recurring, + }; + + serde_json::to_writer_pretty(writer, &data)?; + Ok(data) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::{NewTransaction, TransactionType}; + use chrono::NaiveDate; + + fn setup_db() -> Database { + Database::open_in_memory().unwrap() + } + + #[test] + fn test_json_export_produces_valid_json() { + let db = setup_db(); + let cats = db.list_categories(Some(TransactionType::Expense)).unwrap(); + + let txn = NewTransaction { + amount: 25.0, + transaction_type: TransactionType::Expense, + category_id: cats[0].id, + currency: "USD".to_string(), + exchange_rate: 1.0, + note: Some("Test".to_string()), + date: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap(); + + let mut buf = Vec::new(); + let data = export_json(&db, &mut buf).unwrap(); + + assert_eq!(data.transactions.len(), 1); + assert!(!data.categories.is_empty()); + assert!(data.budgets.is_empty()); + assert!(data.recurring.is_empty()); + + // Verify it's valid JSON by parsing it back + let output = String::from_utf8(buf).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert!(parsed["transactions"].is_array()); + assert!(parsed["categories"].is_array()); + } + + #[test] + fn test_json_export_includes_all_sections() { + let db = setup_db(); + + let mut buf = Vec::new(); + let data = export_json(&db, &mut buf).unwrap(); + + // Even with no transactions, we should have default categories + assert!(!data.categories.is_empty()); + + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("\"transactions\"")); + assert!(output.contains("\"categories\"")); + assert!(output.contains("\"budgets\"")); + assert!(output.contains("\"recurring\"")); + } + + #[test] + fn test_json_export_transaction_fields() { + let db = setup_db(); + let cats = db.list_categories(Some(TransactionType::Income)).unwrap(); + + let txn = NewTransaction { + amount: 500.0, + transaction_type: TransactionType::Income, + category_id: cats[0].id, + currency: "EUR".to_string(), + exchange_rate: 0.92, + note: Some("Freelance".to_string()), + date: NaiveDate::from_ymd_opt(2026, 2, 15).unwrap(), + recurring_id: None, + }; + db.insert_transaction(&txn).unwrap(); + + let mut buf = Vec::new(); + export_json(&db, &mut buf).unwrap(); + + let output = String::from_utf8(buf).unwrap(); + assert!(output.contains("500.0")); + assert!(output.contains("EUR")); + assert!(output.contains("Freelance")); + } +}