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#[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 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 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 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 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 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 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}