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#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::config::paths::EnvelopePaths;
216    use crate::models::{Account, AccountType, Category, CategoryGroup, Money, Transaction};
217    use chrono::NaiveDate;
218    use tempfile::TempDir;
219
220    fn create_test_storage() -> (TempDir, Storage) {
221        let temp_dir = TempDir::new().unwrap();
222        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
223        let mut storage = Storage::new(paths).unwrap();
224        storage.load_all().unwrap();
225        (temp_dir, storage)
226    }
227
228    #[test]
229    fn test_full_export() {
230        let (_temp_dir, storage) = create_test_storage();
231
232        // Create test data
233        let account = Account::new("Checking", AccountType::Checking);
234        storage.accounts.upsert(account.clone()).unwrap();
235        storage.accounts.save().unwrap();
236
237        let group = CategoryGroup::new("Test");
238        storage.categories.upsert_group(group.clone()).unwrap();
239        let cat = Category::new("Groceries", group.id);
240        storage.categories.upsert_category(cat.clone()).unwrap();
241        storage.categories.save().unwrap();
242
243        let mut txn = Transaction::new(
244            account.id,
245            NaiveDate::from_ymd_opt(2025, 1, 15).unwrap(),
246            Money::from_cents(-5000),
247        );
248        txn.category_id = Some(cat.id);
249        storage.transactions.upsert(txn).unwrap();
250
251        // Export
252        let export = FullExport::from_storage(&storage).unwrap();
253
254        assert_eq!(export.schema_version, EXPORT_SCHEMA_VERSION);
255        assert_eq!(export.accounts.len(), 1);
256        assert_eq!(export.categories.len(), 1);
257        assert_eq!(export.transactions.len(), 1);
258        assert!(export.validate().is_ok());
259    }
260
261    #[test]
262    fn test_json_roundtrip() {
263        let (_temp_dir, storage) = create_test_storage();
264
265        // Create test data
266        let account = Account::new("Checking", AccountType::Checking);
267        storage.accounts.upsert(account.clone()).unwrap();
268        storage.accounts.save().unwrap();
269
270        let group = CategoryGroup::new("Test");
271        storage.categories.upsert_group(group.clone()).unwrap();
272        let cat = Category::new("Groceries", group.id);
273        storage.categories.upsert_category(cat.clone()).unwrap();
274        storage.categories.save().unwrap();
275
276        // Export to JSON
277        let mut json_output = Vec::new();
278        export_full_json(&storage, &mut json_output, true).unwrap();
279
280        let json_string = String::from_utf8(json_output).unwrap();
281
282        // Import back
283        let imported = import_from_json(&json_string).unwrap();
284
285        assert_eq!(imported.accounts.len(), 1);
286        assert_eq!(imported.accounts[0].name, "Checking");
287    }
288
289    #[test]
290    fn test_metadata() {
291        let (_temp_dir, storage) = create_test_storage();
292
293        // Create accounts
294        for i in 0..3 {
295            let account = Account::new(format!("Account {}", i), AccountType::Checking);
296            storage.accounts.upsert(account).unwrap();
297        }
298        storage.accounts.save().unwrap();
299
300        let export = FullExport::from_storage(&storage).unwrap();
301
302        assert_eq!(export.metadata.account_count, 3);
303        assert_eq!(export.metadata.transaction_count, 0);
304    }
305}