1use crate::{
14 Amount, Balance, Close, Commodity, Directive, Event, NaiveDate, Note, Open, Pad, Posting,
15 Price, Transaction,
16 format::{FormatConfig, format_directive},
17};
18use rust_decimal::Decimal;
19use std::str::FromStr;
20
21#[derive(Debug, Clone)]
23pub struct EdgeCaseCollection {
24 pub category: String,
26 pub directives: Vec<Directive>,
28}
29
30impl EdgeCaseCollection {
31 pub fn new(category: impl Into<String>, directives: Vec<Directive>) -> Self {
33 Self {
34 category: category.into(),
35 directives,
36 }
37 }
38
39 pub fn to_beancount(&self) -> String {
41 let config = FormatConfig::default();
42 let mut output = format!("; Edge cases: {}\n\n", self.category);
43
44 for directive in &self.directives {
45 output.push_str(&format_directive(directive, &config));
46 output.push_str("\n\n");
47 }
48
49 output
50 }
51}
52
53pub fn generate_all_edge_cases() -> Vec<EdgeCaseCollection> {
55 vec![
56 generate_unicode_edge_cases(),
57 generate_decimal_edge_cases(),
58 generate_hierarchy_edge_cases(),
59 generate_large_transaction_edge_cases(),
60 generate_boundary_date_edge_cases(),
61 generate_special_character_edge_cases(),
62 generate_minimal_edge_cases(),
63 ]
64}
65
66pub fn generate_unicode_edge_cases() -> EdgeCaseCollection {
74 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
75 let open_date = base_date.pred_opt().unwrap();
76
77 let directives = vec![
78 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
80 Directive::Open(Open::new(open_date, "Assets:Bank:Savings")),
81 Directive::Open(Open::new(open_date, "Assets:Cash")),
82 Directive::Open(Open::new(open_date, "Expenses:Food")),
83 Directive::Open(Open::new(open_date, "Expenses:Food:Cafe")),
84 Directive::Open(Open::new(open_date, "Expenses:Food:Groceries")),
85 Directive::Open(Open::new(open_date, "Expenses:Travel")),
86 Directive::Transaction(
88 Transaction::new(base_date, "Café Purchase")
89 .with_flag('*')
90 .with_payee("Bäckerei München")
91 .with_posting(Posting::new(
92 "Expenses:Food:Cafe",
93 Amount::new(dec("5.50"), "EUR"),
94 ))
95 .with_posting(Posting::auto("Assets:Bank:Checking")),
96 ),
97 Directive::Transaction(
99 Transaction::new(base_date, "東京での買い物")
100 .with_flag('*')
101 .with_payee("コンビニ")
102 .with_posting(Posting::new(
103 "Expenses:Food",
104 Amount::new(dec("1000"), "JPY"),
105 ))
106 .with_posting(Posting::auto("Assets:Cash")),
107 ),
108 Directive::Transaction(
110 Transaction::new(base_date, "Покупка продуктов")
111 .with_flag('*')
112 .with_payee("Магазин")
113 .with_posting(Posting::new(
114 "Expenses:Food",
115 Amount::new(dec("500"), "RUB"),
116 ))
117 .with_posting(Posting::auto("Assets:Cash")),
118 ),
119 Directive::Transaction(
121 Transaction::new(base_date, "شراء طعام")
122 .with_flag('*')
123 .with_payee("متجر")
124 .with_posting(Posting::new(
125 "Expenses:Food",
126 Amount::new(dec("100"), "SAR"),
127 ))
128 .with_posting(Posting::auto("Assets:Cash")),
129 ),
130 Directive::Transaction(
132 Transaction::new(base_date, "Grocery run with emoji")
133 .with_flag('*')
134 .with_posting(Posting::new(
135 "Expenses:Food:Groceries",
136 Amount::new(dec("45.99"), "USD"),
137 ))
138 .with_posting(Posting::auto("Assets:Bank:Checking")),
139 ),
140 Directive::Transaction(
142 Transaction::new(base_date, "International trip")
143 .with_flag('*')
144 .with_posting(Posting::new(
145 "Expenses:Travel",
146 Amount::new(dec("2500"), "USD"),
147 ))
148 .with_posting(Posting::auto("Assets:Bank:Savings")),
149 ),
150 Directive::Note(Note::new(
152 base_date,
153 "Assets:Bank:Checking",
154 "Überprüfung der Kontoauszüge für März",
155 )),
156 Directive::Event(Event::new(base_date, "location", "Zürich, Schweiz")),
158 ];
159
160 EdgeCaseCollection::new("unicode", directives)
161}
162
163pub fn generate_decimal_edge_cases() -> EdgeCaseCollection {
171 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
172 let open_date = base_date.pred_opt().unwrap();
173
174 let directives = vec![
175 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
177 Directive::Open(Open::new(open_date, "Assets:Crypto:BTC")),
178 Directive::Open(Open::new(open_date, "Assets:Investments:Stock")),
179 Directive::Open(Open::new(open_date, "Expenses:Test")),
180 Directive::Open(Open::new(open_date, "Equity:Opening")),
181 Directive::Transaction(
183 Transaction::new(base_date, "Bitcoin purchase")
184 .with_flag('*')
185 .with_posting(Posting::new(
186 "Assets:Crypto:BTC",
187 Amount::new(dec("0.00012345"), "BTC"),
188 ))
189 .with_posting(Posting::new(
190 "Assets:Bank:Checking",
191 Amount::new(dec("-5.00"), "USD"),
192 )),
193 ),
194 Directive::Transaction(
196 Transaction::new(base_date, "High precision test")
197 .with_flag('*')
198 .with_posting(Posting::new(
199 "Assets:Investments:Stock",
200 Amount::new(dec("1.1234567890123456"), "MICRO"),
201 ))
202 .with_posting(Posting::auto("Equity:Opening")),
203 ),
204 Directive::Transaction(
206 Transaction::new(base_date, "Large number test")
207 .with_flag('*')
208 .with_posting(Posting::new(
209 "Assets:Bank:Checking",
210 Amount::new(dec("999999999999.99"), "USD"),
211 ))
212 .with_posting(Posting::auto("Equity:Opening")),
213 ),
214 Directive::Transaction(
216 Transaction::new(base_date, "Tiny amount")
217 .with_flag('*')
218 .with_posting(Posting::new(
219 "Expenses:Test",
220 Amount::new(dec("0.00000001"), "USD"),
221 ))
222 .with_posting(Posting::auto("Assets:Bank:Checking")),
223 ),
224 Directive::Transaction(
226 Transaction::new(base_date, "Trailing zeros")
227 .with_flag('*')
228 .with_posting(Posting::new(
229 "Expenses:Test",
230 Amount::new(dec("100.10000"), "USD"),
231 ))
232 .with_posting(Posting::auto("Assets:Bank:Checking")),
233 ),
234 Directive::Transaction(
236 Transaction::new(base_date, "Negative high precision")
237 .with_flag('*')
238 .with_posting(Posting::new(
239 "Expenses:Test",
240 Amount::new(dec("-0.12345678"), "USD"),
241 ))
242 .with_posting(Posting::auto("Assets:Bank:Checking")),
243 ),
244 Directive::Price(Price::new(
246 base_date,
247 "BTC",
248 Amount::new(dec("45678.12345678"), "USD"),
249 )),
250 ];
251
252 EdgeCaseCollection::new("decimals", directives)
253}
254
255pub fn generate_hierarchy_edge_cases() -> EdgeCaseCollection {
259 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
260 let open_date = base_date.pred_opt().unwrap();
261
262 let deep_asset = "Assets:Bank:Region:Country:City:Branch:Department:Team:SubTeam:Account";
264 let deep_expense = "Expenses:Category:SubCategory:Type:SubType:Detail:MoreDetail:Final";
265
266 let directives = vec![
267 Directive::Open(Open::new(open_date, deep_asset)),
269 Directive::Open(Open::new(open_date, deep_expense)),
270 Directive::Open(Open::new(open_date, "Assets:A:B:C:D:E:F:G:H:I:J")),
271 Directive::Open(Open::new(
272 open_date,
273 "Liabilities:Debt:Type:Lender:Account:SubAccount",
274 )),
275 Directive::Open(Open::new(open_date, "Equity:Opening")),
276 Directive::Transaction(
278 Transaction::new(base_date, "Deep hierarchy transfer")
279 .with_flag('*')
280 .with_posting(Posting::new(
281 deep_expense,
282 Amount::new(dec("100.00"), "USD"),
283 ))
284 .with_posting(Posting::auto(deep_asset)),
285 ),
286 Directive::Balance(Balance::new(
288 base_date,
289 deep_asset,
290 Amount::new(dec("-100.00"), "USD"),
291 )),
292 Directive::Pad(Pad::new(
294 base_date,
295 "Assets:A:B:C:D:E:F:G:H:I:J",
296 "Equity:Opening",
297 )),
298 ];
299
300 EdgeCaseCollection::new("hierarchy", directives)
301}
302
303pub fn generate_large_transaction_edge_cases() -> EdgeCaseCollection {
305 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
306 let open_date = base_date.pred_opt().unwrap();
307
308 let mut directives: Vec<Directive> = (0..25)
310 .map(|i| Directive::Open(Open::new(open_date, format!("Expenses:Category{i}"))))
311 .collect();
312
313 directives.push(Directive::Open(Open::new(
314 open_date,
315 "Assets:Bank:Checking",
316 )));
317
318 let mut txn =
320 Transaction::new(base_date, "Expense allocation with 20 categories").with_flag('*');
321
322 for i in 0..20 {
323 txn = txn.with_posting(Posting::new(
324 format!("Expenses:Category{i}"),
325 Amount::new(dec("10.00"), "USD"),
326 ));
327 }
328
329 txn = txn.with_posting(Posting::new(
331 "Assets:Bank:Checking",
332 Amount::new(dec("-200.00"), "USD"),
333 ));
334
335 directives.push(Directive::Transaction(txn));
336
337 let mut txn2 = Transaction::new(base_date, "Tagged transaction")
339 .with_flag('*')
340 .with_posting(Posting::new(
341 "Expenses:Category0",
342 Amount::new(dec("50.00"), "USD"),
343 ))
344 .with_posting(Posting::auto("Assets:Bank:Checking"));
345
346 for i in 0..10 {
347 txn2 = txn2.with_tag(format!("tag{i}"));
348 }
349 for i in 0..10 {
350 txn2 = txn2.with_link(format!("link{i}"));
351 }
352
353 directives.push(Directive::Transaction(txn2));
354
355 EdgeCaseCollection::new("large-transactions", directives)
356}
357
358pub fn generate_boundary_date_edge_cases() -> EdgeCaseCollection {
360 let early_date = NaiveDate::from_ymd_opt(1900, 1, 1).unwrap();
362 let late_date = NaiveDate::from_ymd_opt(2099, 12, 31).unwrap();
364 let leap_date = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
366 let end_jan = NaiveDate::from_ymd_opt(2024, 1, 31).unwrap();
368 let end_apr = NaiveDate::from_ymd_opt(2024, 4, 30).unwrap();
369
370 let directives = vec![
371 Directive::Open(Open::new(
373 NaiveDate::from_ymd_opt(1899, 12, 31).unwrap(),
374 "Assets:Historical:Account",
375 )),
376 Directive::Open(Open::new(
377 NaiveDate::from_ymd_opt(1899, 12, 31).unwrap(),
378 "Equity:Opening",
379 )),
380 Directive::Transaction(
382 Transaction::new(early_date, "Historical transaction from 1900")
383 .with_flag('*')
384 .with_posting(Posting::new(
385 "Assets:Historical:Account",
386 Amount::new(dec("1.00"), "USD"),
387 ))
388 .with_posting(Posting::auto("Equity:Opening")),
389 ),
390 Directive::Transaction(
392 Transaction::new(late_date, "Far future transaction")
393 .with_flag('*')
394 .with_posting(Posting::new(
395 "Assets:Historical:Account",
396 Amount::new(dec("1000000.00"), "USD"),
397 ))
398 .with_posting(Posting::auto("Equity:Opening")),
399 ),
400 Directive::Transaction(
402 Transaction::new(leap_date, "Leap day transaction")
403 .with_flag('*')
404 .with_posting(Posting::new(
405 "Assets:Historical:Account",
406 Amount::new(dec("29.02"), "USD"),
407 ))
408 .with_posting(Posting::auto("Equity:Opening")),
409 ),
410 Directive::Transaction(
412 Transaction::new(end_jan, "End of January")
413 .with_flag('*')
414 .with_posting(Posting::new(
415 "Assets:Historical:Account",
416 Amount::new(dec("31.00"), "USD"),
417 ))
418 .with_posting(Posting::auto("Equity:Opening")),
419 ),
420 Directive::Transaction(
421 Transaction::new(end_apr, "End of April")
422 .with_flag('*')
423 .with_posting(Posting::new(
424 "Assets:Historical:Account",
425 Amount::new(dec("30.00"), "USD"),
426 ))
427 .with_posting(Posting::auto("Equity:Opening")),
428 ),
429 ];
430
431 EdgeCaseCollection::new("boundary-dates", directives)
432}
433
434pub fn generate_special_character_edge_cases() -> EdgeCaseCollection {
438 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
439 let open_date = base_date.pred_opt().unwrap();
440
441 let directives = vec![
442 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
443 Directive::Open(Open::new(open_date, "Expenses:Test")),
444 Directive::Transaction(
446 Transaction::new(base_date, "Purchase at Joe's Diner")
447 .with_flag('*')
448 .with_posting(Posting::new(
449 "Expenses:Test",
450 Amount::new(dec("25.00"), "USD"),
451 ))
452 .with_posting(Posting::auto("Assets:Bank:Checking")),
453 ),
454 Directive::Transaction(
456 Transaction::new(base_date, r"Path: C:\Users\Documents\file.txt")
457 .with_flag('*')
458 .with_posting(Posting::new(
459 "Expenses:Test",
460 Amount::new(dec("10.00"), "USD"),
461 ))
462 .with_posting(Posting::auto("Assets:Bank:Checking")),
463 ),
464 Directive::Transaction(
466 Transaction::new(base_date, "Multi-line description split across text")
467 .with_flag('*')
468 .with_posting(Posting::new(
469 "Expenses:Test",
470 Amount::new(dec("5.00"), "USD"),
471 ))
472 .with_posting(Posting::auto("Assets:Bank:Checking")),
473 ),
474 Directive::Transaction(
476 Transaction::new(base_date, "A".repeat(200))
477 .with_flag('*')
478 .with_posting(Posting::new(
479 "Expenses:Test",
480 Amount::new(dec("1.00"), "USD"),
481 ))
482 .with_posting(Posting::auto("Assets:Bank:Checking")),
483 ),
484 Directive::Transaction(
486 Transaction::new(base_date, "Regular narration")
487 .with_flag('*')
488 .with_payee("Company Name") .with_posting(Posting::new(
490 "Expenses:Test",
491 Amount::new(dec("15.00"), "USD"),
492 ))
493 .with_posting(Posting::auto("Assets:Bank:Checking")),
494 ),
495 ];
496
497 EdgeCaseCollection::new("special-characters", directives)
498}
499
500pub fn generate_minimal_edge_cases() -> EdgeCaseCollection {
504 let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
505
506 let directives = vec![
507 Directive::Open(Open::new(base_date, "Assets:Minimal")),
509 Directive::Open(
511 Open::new(base_date, "Assets:WithCurrency").with_currencies(vec!["USD".into()]),
512 ),
513 Directive::Close(Close::new(base_date.succ_opt().unwrap(), "Assets:Minimal")),
515 Directive::Commodity(Commodity::new(base_date, "MINI")),
517 Directive::Price(Price::new(
519 base_date,
520 "MINI",
521 Amount::new(dec("1.00"), "USD"),
522 )),
523 Directive::Note(Note::new(base_date, "Assets:WithCurrency", "A note")),
525 Directive::Event(Event::new(base_date, "type", "value")),
527 Directive::Transaction(Transaction::new(base_date, "").with_flag('*').with_posting(
529 Posting::new("Assets:WithCurrency", Amount::new(dec("0.00"), "USD")),
530 )),
531 Directive::Transaction(
533 Transaction::new(base_date, "Auto-balanced")
534 .with_flag('*')
535 .with_posting(Posting::new(
536 "Assets:WithCurrency",
537 Amount::new(dec("100.00"), "USD"),
538 ))
539 .with_posting(Posting::auto("Assets:WithCurrency")),
540 ),
541 ];
542
543 EdgeCaseCollection::new("minimal", directives)
544}
545
546pub fn generate_all_edge_cases_beancount() -> String {
550 let mut output = String::new();
551 output.push_str("; Synthetic edge case beancount file\n");
552 output.push_str("; Generated by rustledger synthetic module\n\n");
553
554 for collection in generate_all_edge_cases() {
555 output.push_str(&format!(
556 "\n; === {} ===\n\n",
557 collection.category.to_uppercase()
558 ));
559 output.push_str(&collection.to_beancount());
560 }
561
562 output
563}
564
565fn dec(s: &str) -> Decimal {
567 Decimal::from_str(s).expect("Invalid decimal string")
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 #[test]
575 fn test_generate_unicode_edge_cases() {
576 let collection = generate_unicode_edge_cases();
577 assert!(!collection.directives.is_empty());
578 assert_eq!(collection.category, "unicode");
579
580 let text = collection.to_beancount();
581 assert!(text.contains("Café"));
582 assert!(text.contains("東京"));
583 }
584
585 #[test]
586 fn test_generate_decimal_edge_cases() {
587 let collection = generate_decimal_edge_cases();
588 assert!(!collection.directives.is_empty());
589
590 let text = collection.to_beancount();
591 assert!(text.contains("0.00012345"));
592 }
593
594 #[test]
595 fn test_generate_all_edge_cases() {
596 let collections = generate_all_edge_cases();
597 assert!(!collections.is_empty());
598
599 let categories: Vec<_> = collections.iter().map(|c| c.category.as_str()).collect();
601 assert!(categories.contains(&"unicode"));
602 assert!(categories.contains(&"decimals"));
603 assert!(categories.contains(&"hierarchy"));
604 }
605
606 #[test]
607 fn test_generate_all_edge_cases_beancount() {
608 let text = generate_all_edge_cases_beancount();
609 assert!(text.contains("UNICODE"));
610 assert!(text.contains("DECIMALS"));
611 assert!(text.contains("HIERARCHY"));
612 }
613}