envelope_cli/export/
json.rs

1//! JSON Export functionality
2//!
3//! Exports the complete database to JSON format with schema versioning.
4
5use 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
12/// Current export schema version
13pub const EXPORT_SCHEMA_VERSION: &str = "1.0.0";
14
15/// Full database export structure
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct FullExport {
18    /// Schema version for compatibility checking
19    pub schema_version: String,
20
21    /// Export timestamp
22    pub exported_at: DateTime<Utc>,
23
24    /// Application version that created the export
25    pub app_version: String,
26
27    /// All accounts
28    pub accounts: Vec<Account>,
29
30    /// All category groups
31    pub category_groups: Vec<CategoryGroup>,
32
33    /// All categories
34    pub categories: Vec<Category>,
35
36    /// All transactions
37    pub transactions: Vec<Transaction>,
38
39    /// All budget allocations
40    pub allocations: Vec<BudgetAllocation>,
41
42    /// All payees
43    pub payees: Vec<Payee>,
44
45    /// Export metadata
46    pub metadata: ExportMetadata,
47}
48
49/// Export metadata for reference
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ExportMetadata {
52    /// Total number of accounts
53    pub account_count: usize,
54
55    /// Total number of transactions
56    pub transaction_count: usize,
57
58    /// Total number of categories
59    pub category_count: usize,
60
61    /// Total number of allocations
62    pub allocation_count: usize,
63
64    /// Total number of payees
65    pub payee_count: usize,
66
67    /// Date range of transactions (earliest)
68    pub earliest_transaction: Option<String>,
69
70    /// Date range of transactions (latest)
71    pub latest_transaction: Option<String>,
72}
73
74impl FullExport {
75    /// Create a new full export from storage
76    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        // Calculate metadata
85        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    /// Validate the export structure
122    pub fn validate(&self) -> Result<(), String> {
123        // Check schema version
124        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        // Check referential integrity
132        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        // Validate transactions reference valid accounts
140        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        // Validate categories reference valid groups
158        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        // Validate allocations reference valid categories
168        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
181/// Export the full database to JSON
182pub 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
199/// Import from a JSON export (for verification/restore)
200pub 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    // Validate the import
205    export
206        .validate()
207        .map_err(crate::error::EnvelopeError::Import)?;
208
209    Ok(export)
210}
211
212/// Result of restoring from an export file
213#[derive(Debug, Default)]
214pub struct ExportRestoreResult {
215    /// Number of accounts restored
216    pub accounts_restored: usize,
217    /// Number of category groups restored
218    pub category_groups_restored: usize,
219    /// Number of categories restored
220    pub categories_restored: usize,
221    /// Number of transactions restored
222    pub transactions_restored: usize,
223    /// Number of allocations restored
224    pub allocations_restored: usize,
225    /// Number of payees restored
226    pub payees_restored: usize,
227    /// Schema version of the restored export
228    pub schema_version: String,
229    /// Date the export was created
230    pub exported_at: chrono::DateTime<chrono::Utc>,
231}
232
233impl ExportRestoreResult {
234    /// Get a summary of what was restored
235    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
248/// Restore data from a FullExport to storage
249///
250/// This will overwrite all existing data with the export contents.
251pub 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    // Restore accounts
262    for account in &export.accounts {
263        storage.accounts.upsert(account.clone())?;
264        result.accounts_restored += 1;
265    }
266    storage.accounts.save()?;
267
268    // Restore category groups first (categories depend on them)
269    for group in &export.category_groups {
270        storage.categories.upsert_group(group.clone())?;
271        result.category_groups_restored += 1;
272    }
273
274    // Restore categories
275    for category in &export.categories {
276        storage.categories.upsert_category(category.clone())?;
277        result.categories_restored += 1;
278    }
279    storage.categories.save()?;
280
281    // Restore transactions
282    for txn in &export.transactions {
283        storage.transactions.upsert(txn.clone())?;
284        result.transactions_restored += 1;
285    }
286    storage.transactions.save()?;
287
288    // Restore allocations
289    for alloc in &export.allocations {
290        storage.budget.upsert(alloc.clone())?;
291        result.allocations_restored += 1;
292    }
293    storage.budget.save()?;
294
295    // Restore payees
296    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        // Create test data
326        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        // Export
345        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        // Create test data
359        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        // Export to JSON
370        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        // Import back
376        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        // Create accounts
387        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}