use crate::error::EnvelopeResult;
use crate::models::{Account, BudgetAllocation, Category, CategoryGroup, Payee, Transaction};
use crate::storage::Storage;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::Write;
pub const EXPORT_SCHEMA_VERSION: &str = "1.0.0";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FullExport {
pub schema_version: String,
pub exported_at: DateTime<Utc>,
pub app_version: String,
pub accounts: Vec<Account>,
pub category_groups: Vec<CategoryGroup>,
pub categories: Vec<Category>,
pub transactions: Vec<Transaction>,
pub allocations: Vec<BudgetAllocation>,
pub payees: Vec<Payee>,
pub metadata: ExportMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExportMetadata {
pub account_count: usize,
pub transaction_count: usize,
pub category_count: usize,
pub allocation_count: usize,
pub payee_count: usize,
pub earliest_transaction: Option<String>,
pub latest_transaction: Option<String>,
}
impl FullExport {
pub fn from_storage(storage: &Storage) -> EnvelopeResult<Self> {
let accounts = storage.accounts.get_all()?;
let category_groups = storage.categories.get_all_groups()?;
let categories = storage.categories.get_all_categories()?;
let transactions = storage.transactions.get_all()?;
let allocations = storage.budget.get_all()?;
let payees = storage.payees.get_all()?;
let earliest_transaction = transactions
.iter()
.map(|t| t.date)
.min()
.map(|d| d.to_string());
let latest_transaction = transactions
.iter()
.map(|t| t.date)
.max()
.map(|d| d.to_string());
let metadata = ExportMetadata {
account_count: accounts.len(),
transaction_count: transactions.len(),
category_count: categories.len(),
allocation_count: allocations.len(),
payee_count: payees.len(),
earliest_transaction,
latest_transaction,
};
Ok(Self {
schema_version: EXPORT_SCHEMA_VERSION.to_string(),
exported_at: Utc::now(),
app_version: env!("CARGO_PKG_VERSION").to_string(),
accounts,
category_groups,
categories,
transactions,
allocations,
payees,
metadata,
})
}
pub fn validate(&self) -> Result<(), String> {
if self.schema_version != EXPORT_SCHEMA_VERSION {
return Err(format!(
"Schema version mismatch: expected {}, got {}",
EXPORT_SCHEMA_VERSION, self.schema_version
));
}
let account_ids: std::collections::HashSet<_> =
self.accounts.iter().map(|a| a.id).collect();
let category_ids: std::collections::HashSet<_> =
self.categories.iter().map(|c| c.id).collect();
let group_ids: std::collections::HashSet<_> =
self.category_groups.iter().map(|g| g.id).collect();
for txn in &self.transactions {
if !account_ids.contains(&txn.account_id) {
return Err(format!(
"Transaction {} references unknown account {}",
txn.id, txn.account_id
));
}
if let Some(cat_id) = txn.category_id {
if !category_ids.contains(&cat_id) {
return Err(format!(
"Transaction {} references unknown category {}",
txn.id, cat_id
));
}
}
}
for cat in &self.categories {
if !group_ids.contains(&cat.group_id) {
return Err(format!(
"Category {} references unknown group {}",
cat.id, cat.group_id
));
}
}
for alloc in &self.allocations {
if !category_ids.contains(&alloc.category_id) {
return Err(format!(
"Allocation for category {} references unknown category",
alloc.category_id
));
}
}
Ok(())
}
}
pub fn export_full_json<W: Write>(
storage: &Storage,
writer: &mut W,
pretty: bool,
) -> EnvelopeResult<()> {
let export = FullExport::from_storage(storage)?;
if pretty {
serde_json::to_writer_pretty(writer, &export)
} else {
serde_json::to_writer(writer, &export)
}
.map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
Ok(())
}
pub fn import_from_json(json_str: &str) -> EnvelopeResult<FullExport> {
let export: FullExport = serde_json::from_str(json_str)
.map_err(|e| crate::error::EnvelopeError::Import(e.to_string()))?;
export
.validate()
.map_err(crate::error::EnvelopeError::Import)?;
Ok(export)
}
#[derive(Debug, Default)]
pub struct ExportRestoreResult {
pub accounts_restored: usize,
pub category_groups_restored: usize,
pub categories_restored: usize,
pub transactions_restored: usize,
pub allocations_restored: usize,
pub payees_restored: usize,
pub schema_version: String,
pub exported_at: chrono::DateTime<chrono::Utc>,
}
impl ExportRestoreResult {
pub fn summary(&self) -> String {
format!(
"Restored: {} accounts, {} groups, {} categories, {} transactions, {} allocations, {} payees",
self.accounts_restored,
self.category_groups_restored,
self.categories_restored,
self.transactions_restored,
self.allocations_restored,
self.payees_restored
)
}
}
pub fn restore_from_export(
storage: &crate::storage::Storage,
export: &FullExport,
) -> EnvelopeResult<ExportRestoreResult> {
let mut result = ExportRestoreResult {
schema_version: export.schema_version.clone(),
exported_at: export.exported_at,
..Default::default()
};
for account in &export.accounts {
storage.accounts.upsert(account.clone())?;
result.accounts_restored += 1;
}
storage.accounts.save()?;
for group in &export.category_groups {
storage.categories.upsert_group(group.clone())?;
result.category_groups_restored += 1;
}
for category in &export.categories {
storage.categories.upsert_category(category.clone())?;
result.categories_restored += 1;
}
storage.categories.save()?;
for txn in &export.transactions {
storage.transactions.upsert(txn.clone())?;
result.transactions_restored += 1;
}
storage.transactions.save()?;
for alloc in &export.allocations {
storage.budget.upsert(alloc.clone())?;
result.allocations_restored += 1;
}
storage.budget.save()?;
for payee in &export.payees {
storage.payees.upsert(payee.clone())?;
result.payees_restored += 1;
}
storage.payees.save()?;
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::paths::EnvelopePaths;
use crate::models::{Account, AccountType, Category, CategoryGroup, Money, Transaction};
use chrono::NaiveDate;
use tempfile::TempDir;
fn create_test_storage() -> (TempDir, Storage) {
let temp_dir = TempDir::new().unwrap();
let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
let mut storage = Storage::new(paths).unwrap();
storage.load_all().unwrap();
(temp_dir, storage)
}
#[test]
fn test_full_export() {
let (_temp_dir, storage) = create_test_storage();
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
storage.accounts.save().unwrap();
let group = CategoryGroup::new("Test");
storage.categories.upsert_group(group.clone()).unwrap();
let cat = Category::new("Groceries", group.id);
storage.categories.upsert_category(cat.clone()).unwrap();
storage.categories.save().unwrap();
let mut txn = Transaction::new(
account.id,
NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
Money::from_cents(-5000),
);
txn.category_id = Some(cat.id);
storage.transactions.upsert(txn).unwrap();
let export = FullExport::from_storage(&storage).unwrap();
assert_eq!(export.schema_version, EXPORT_SCHEMA_VERSION);
assert_eq!(export.accounts.len(), 1);
assert_eq!(export.categories.len(), 1);
assert_eq!(export.transactions.len(), 1);
assert!(export.validate().is_ok());
}
#[test]
fn test_json_roundtrip() {
let (_temp_dir, storage) = create_test_storage();
let account = Account::new("Checking", AccountType::Checking);
storage.accounts.upsert(account.clone()).unwrap();
storage.accounts.save().unwrap();
let group = CategoryGroup::new("Test");
storage.categories.upsert_group(group.clone()).unwrap();
let cat = Category::new("Groceries", group.id);
storage.categories.upsert_category(cat.clone()).unwrap();
storage.categories.save().unwrap();
let mut json_output = Vec::new();
export_full_json(&storage, &mut json_output, true).unwrap();
let json_string = String::from_utf8(json_output).unwrap();
let imported = import_from_json(&json_string).unwrap();
assert_eq!(imported.accounts.len(), 1);
assert_eq!(imported.accounts[0].name, "Checking");
}
#[test]
fn test_metadata() {
let (_temp_dir, storage) = create_test_storage();
for i in 0..3 {
let account = Account::new(format!("Account {}", i), AccountType::Checking);
storage.accounts.upsert(account).unwrap();
}
storage.accounts.save().unwrap();
let export = FullExport::from_storage(&storage).unwrap();
assert_eq!(export.metadata.account_count, 3);
assert_eq!(export.metadata.transaction_count, 0);
}
}