1use crate::error::EnvelopeResult;
6use crate::models::{Account, BudgetAllocation, Category, CategoryGroup, Payee, Transaction};
7use crate::storage::Storage;
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11
12pub const EXPORT_SCHEMA_VERSION: &str = "1.0.0";
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct FullExport {
18 pub schema_version: String,
20
21 pub exported_at: DateTime<Utc>,
23
24 pub app_version: String,
26
27 pub accounts: Vec<Account>,
29
30 pub category_groups: Vec<CategoryGroup>,
32
33 pub categories: Vec<Category>,
35
36 pub transactions: Vec<Transaction>,
38
39 pub allocations: Vec<BudgetAllocation>,
41
42 pub payees: Vec<Payee>,
44
45 pub metadata: ExportMetadata,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExportMetadata {
52 pub account_count: usize,
54
55 pub transaction_count: usize,
57
58 pub category_count: usize,
60
61 pub allocation_count: usize,
63
64 pub payee_count: usize,
66
67 pub earliest_transaction: Option<String>,
69
70 pub latest_transaction: Option<String>,
72}
73
74impl FullExport {
75 pub fn from_storage(storage: &Storage) -> EnvelopeResult<Self> {
77 let accounts = storage.accounts.get_all()?;
78 let category_groups = storage.categories.get_all_groups()?;
79 let categories = storage.categories.get_all_categories()?;
80 let transactions = storage.transactions.get_all()?;
81 let allocations = storage.budget.get_all()?;
82 let payees = storage.payees.get_all()?;
83
84 let earliest_transaction = transactions
86 .iter()
87 .map(|t| t.date)
88 .min()
89 .map(|d| d.to_string());
90
91 let latest_transaction = transactions
92 .iter()
93 .map(|t| t.date)
94 .max()
95 .map(|d| d.to_string());
96
97 let metadata = ExportMetadata {
98 account_count: accounts.len(),
99 transaction_count: transactions.len(),
100 category_count: categories.len(),
101 allocation_count: allocations.len(),
102 payee_count: payees.len(),
103 earliest_transaction,
104 latest_transaction,
105 };
106
107 Ok(Self {
108 schema_version: EXPORT_SCHEMA_VERSION.to_string(),
109 exported_at: Utc::now(),
110 app_version: env!("CARGO_PKG_VERSION").to_string(),
111 accounts,
112 category_groups,
113 categories,
114 transactions,
115 allocations,
116 payees,
117 metadata,
118 })
119 }
120
121 pub fn validate(&self) -> Result<(), String> {
123 if self.schema_version != EXPORT_SCHEMA_VERSION {
125 return Err(format!(
126 "Schema version mismatch: expected {}, got {}",
127 EXPORT_SCHEMA_VERSION, self.schema_version
128 ));
129 }
130
131 let account_ids: std::collections::HashSet<_> =
133 self.accounts.iter().map(|a| a.id).collect();
134 let category_ids: std::collections::HashSet<_> =
135 self.categories.iter().map(|c| c.id).collect();
136 let group_ids: std::collections::HashSet<_> =
137 self.category_groups.iter().map(|g| g.id).collect();
138
139 for txn in &self.transactions {
141 if !account_ids.contains(&txn.account_id) {
142 return Err(format!(
143 "Transaction {} references unknown account {}",
144 txn.id, txn.account_id
145 ));
146 }
147 if let Some(cat_id) = txn.category_id {
148 if !category_ids.contains(&cat_id) {
149 return Err(format!(
150 "Transaction {} references unknown category {}",
151 txn.id, cat_id
152 ));
153 }
154 }
155 }
156
157 for cat in &self.categories {
159 if !group_ids.contains(&cat.group_id) {
160 return Err(format!(
161 "Category {} references unknown group {}",
162 cat.id, cat.group_id
163 ));
164 }
165 }
166
167 for alloc in &self.allocations {
169 if !category_ids.contains(&alloc.category_id) {
170 return Err(format!(
171 "Allocation for category {} references unknown category",
172 alloc.category_id
173 ));
174 }
175 }
176
177 Ok(())
178 }
179}
180
181pub fn export_full_json<W: Write>(
183 storage: &Storage,
184 writer: &mut W,
185 pretty: bool,
186) -> EnvelopeResult<()> {
187 let export = FullExport::from_storage(storage)?;
188
189 if pretty {
190 serde_json::to_writer_pretty(writer, &export)
191 } else {
192 serde_json::to_writer(writer, &export)
193 }
194 .map_err(|e| crate::error::EnvelopeError::Export(e.to_string()))?;
195
196 Ok(())
197}
198
199pub fn import_from_json(json_str: &str) -> EnvelopeResult<FullExport> {
201 let export: FullExport = serde_json::from_str(json_str)
202 .map_err(|e| crate::error::EnvelopeError::Import(e.to_string()))?;
203
204 export
206 .validate()
207 .map_err(crate::error::EnvelopeError::Import)?;
208
209 Ok(export)
210}
211
212#[derive(Debug, Default)]
214pub struct ExportRestoreResult {
215 pub accounts_restored: usize,
217 pub category_groups_restored: usize,
219 pub categories_restored: usize,
221 pub transactions_restored: usize,
223 pub allocations_restored: usize,
225 pub payees_restored: usize,
227 pub schema_version: String,
229 pub exported_at: chrono::DateTime<chrono::Utc>,
231}
232
233impl ExportRestoreResult {
234 pub fn summary(&self) -> String {
236 format!(
237 "Restored: {} accounts, {} groups, {} categories, {} transactions, {} allocations, {} payees",
238 self.accounts_restored,
239 self.category_groups_restored,
240 self.categories_restored,
241 self.transactions_restored,
242 self.allocations_restored,
243 self.payees_restored
244 )
245 }
246}
247
248pub fn restore_from_export(
252 storage: &crate::storage::Storage,
253 export: &FullExport,
254) -> EnvelopeResult<ExportRestoreResult> {
255 let mut result = ExportRestoreResult {
256 schema_version: export.schema_version.clone(),
257 exported_at: export.exported_at,
258 ..Default::default()
259 };
260
261 for account in &export.accounts {
263 storage.accounts.upsert(account.clone())?;
264 result.accounts_restored += 1;
265 }
266 storage.accounts.save()?;
267
268 for group in &export.category_groups {
270 storage.categories.upsert_group(group.clone())?;
271 result.category_groups_restored += 1;
272 }
273
274 for category in &export.categories {
276 storage.categories.upsert_category(category.clone())?;
277 result.categories_restored += 1;
278 }
279 storage.categories.save()?;
280
281 for txn in &export.transactions {
283 storage.transactions.upsert(txn.clone())?;
284 result.transactions_restored += 1;
285 }
286 storage.transactions.save()?;
287
288 for alloc in &export.allocations {
290 storage.budget.upsert(alloc.clone())?;
291 result.allocations_restored += 1;
292 }
293 storage.budget.save()?;
294
295 for payee in &export.payees {
297 storage.payees.upsert(payee.clone())?;
298 result.payees_restored += 1;
299 }
300 storage.payees.save()?;
301
302 Ok(result)
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::config::paths::EnvelopePaths;
309 use crate::models::{Account, AccountType, Category, CategoryGroup, Money, Transaction};
310 use chrono::NaiveDate;
311 use tempfile::TempDir;
312
313 fn create_test_storage() -> (TempDir, Storage) {
314 let temp_dir = TempDir::new().unwrap();
315 let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
316 let mut storage = Storage::new(paths).unwrap();
317 storage.load_all().unwrap();
318 (temp_dir, storage)
319 }
320
321 #[test]
322 fn test_full_export() {
323 let (_temp_dir, storage) = create_test_storage();
324
325 let account = Account::new("Checking", AccountType::Checking);
327 storage.accounts.upsert(account.clone()).unwrap();
328 storage.accounts.save().unwrap();
329
330 let group = CategoryGroup::new("Test");
331 storage.categories.upsert_group(group.clone()).unwrap();
332 let cat = Category::new("Groceries", group.id);
333 storage.categories.upsert_category(cat.clone()).unwrap();
334 storage.categories.save().unwrap();
335
336 let mut txn = Transaction::new(
337 account.id,
338 NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
339 Money::from_cents(-5000),
340 );
341 txn.category_id = Some(cat.id);
342 storage.transactions.upsert(txn).unwrap();
343
344 let export = FullExport::from_storage(&storage).unwrap();
346
347 assert_eq!(export.schema_version, EXPORT_SCHEMA_VERSION);
348 assert_eq!(export.accounts.len(), 1);
349 assert_eq!(export.categories.len(), 1);
350 assert_eq!(export.transactions.len(), 1);
351 assert!(export.validate().is_ok());
352 }
353
354 #[test]
355 fn test_json_roundtrip() {
356 let (_temp_dir, storage) = create_test_storage();
357
358 let account = Account::new("Checking", AccountType::Checking);
360 storage.accounts.upsert(account.clone()).unwrap();
361 storage.accounts.save().unwrap();
362
363 let group = CategoryGroup::new("Test");
364 storage.categories.upsert_group(group.clone()).unwrap();
365 let cat = Category::new("Groceries", group.id);
366 storage.categories.upsert_category(cat.clone()).unwrap();
367 storage.categories.save().unwrap();
368
369 let mut json_output = Vec::new();
371 export_full_json(&storage, &mut json_output, true).unwrap();
372
373 let json_string = String::from_utf8(json_output).unwrap();
374
375 let imported = import_from_json(&json_string).unwrap();
377
378 assert_eq!(imported.accounts.len(), 1);
379 assert_eq!(imported.accounts[0].name, "Checking");
380 }
381
382 #[test]
383 fn test_metadata() {
384 let (_temp_dir, storage) = create_test_storage();
385
386 for i in 0..3 {
388 let account = Account::new(format!("Account {}", i), AccountType::Checking);
389 storage.accounts.upsert(account).unwrap();
390 }
391 storage.accounts.save().unwrap();
392
393 let export = FullExport::from_storage(&storage).unwrap();
394
395 assert_eq!(export.metadata.account_count, 3);
396 assert_eq!(export.metadata.transaction_count, 0);
397 }
398}