1use crate::{
14 Amount, Balance, Close, Commodity, Directive, Event, Note, Open, Pad, Posting, Price,
15 Transaction,
16 format::{FormatConfig, FormatLine, format_directive_lines, render_lines},
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 {
44 let config = FormatConfig::default();
45 let mut lines: Vec<FormatLine> = vec![
46 FormatLine::Plain(format!("; Edge cases: {}", self.category)),
47 FormatLine::Plain(String::new()),
48 ];
49
50 for directive in &self.directives {
51 lines.extend(format_directive_lines(directive, &config));
52 lines.push(FormatLine::Plain(String::new()));
53 }
54
55 render_lines(&lines, &config.alignment)
56 }
57}
58
59pub fn generate_all_edge_cases() -> Vec<EdgeCaseCollection> {
61 vec![
62 generate_unicode_edge_cases(),
63 generate_decimal_edge_cases(),
64 generate_hierarchy_edge_cases(),
65 generate_large_transaction_edge_cases(),
66 generate_boundary_date_edge_cases(),
67 generate_special_character_edge_cases(),
68 generate_minimal_edge_cases(),
69 ]
70}
71
72pub fn generate_unicode_edge_cases() -> EdgeCaseCollection {
80 let base_date = crate::naive_date(2024, 1, 1).unwrap();
81 let open_date = base_date.yesterday().ok().unwrap();
82
83 let directives = vec![
84 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
86 Directive::Open(Open::new(open_date, "Assets:Bank:Savings")),
87 Directive::Open(Open::new(open_date, "Assets:Cash")),
88 Directive::Open(Open::new(open_date, "Expenses:Food")),
89 Directive::Open(Open::new(open_date, "Expenses:Food:Cafe")),
90 Directive::Open(Open::new(open_date, "Expenses:Food:Groceries")),
91 Directive::Open(Open::new(open_date, "Expenses:Travel")),
92 Directive::Transaction(
94 Transaction::new(base_date, "Café Purchase")
95 .with_flag('*')
96 .with_payee("Bäckerei München")
97 .with_synthesized_posting(Posting::new(
98 "Expenses:Food:Cafe",
99 Amount::new(dec("5.50"), "EUR"),
100 ))
101 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
102 ),
103 Directive::Transaction(
105 Transaction::new(base_date, "東京での買い物")
106 .with_flag('*')
107 .with_payee("コンビニ")
108 .with_synthesized_posting(Posting::new(
109 "Expenses:Food",
110 Amount::new(dec("1000"), "JPY"),
111 ))
112 .with_synthesized_posting(Posting::auto("Assets:Cash")),
113 ),
114 Directive::Transaction(
116 Transaction::new(base_date, "Покупка продуктов")
117 .with_flag('*')
118 .with_payee("Магазин")
119 .with_synthesized_posting(Posting::new(
120 "Expenses:Food",
121 Amount::new(dec("500"), "RUB"),
122 ))
123 .with_synthesized_posting(Posting::auto("Assets:Cash")),
124 ),
125 Directive::Transaction(
127 Transaction::new(base_date, "شراء طعام")
128 .with_flag('*')
129 .with_payee("متجر")
130 .with_synthesized_posting(Posting::new(
131 "Expenses:Food",
132 Amount::new(dec("100"), "SAR"),
133 ))
134 .with_synthesized_posting(Posting::auto("Assets:Cash")),
135 ),
136 Directive::Transaction(
138 Transaction::new(base_date, "Grocery run with emoji")
139 .with_flag('*')
140 .with_synthesized_posting(Posting::new(
141 "Expenses:Food:Groceries",
142 Amount::new(dec("45.99"), "USD"),
143 ))
144 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
145 ),
146 Directive::Transaction(
148 Transaction::new(base_date, "International trip")
149 .with_flag('*')
150 .with_synthesized_posting(Posting::new(
151 "Expenses:Travel",
152 Amount::new(dec("2500"), "USD"),
153 ))
154 .with_synthesized_posting(Posting::auto("Assets:Bank:Savings")),
155 ),
156 Directive::Note(Note::new(
158 base_date,
159 "Assets:Bank:Checking",
160 "Überprüfung der Kontoauszüge für März",
161 )),
162 Directive::Event(Event::new(base_date, "location", "Zürich, Schweiz")),
164 ];
165
166 EdgeCaseCollection::new("unicode", directives)
167}
168
169pub fn generate_decimal_edge_cases() -> EdgeCaseCollection {
177 let base_date = crate::naive_date(2024, 1, 1).unwrap();
178 let open_date = base_date.yesterday().ok().unwrap();
179
180 let directives = vec![
181 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
183 Directive::Open(Open::new(open_date, "Assets:Crypto:BTC")),
184 Directive::Open(Open::new(open_date, "Assets:Investments:Stock")),
185 Directive::Open(Open::new(open_date, "Expenses:Test")),
186 Directive::Open(Open::new(open_date, "Equity:Opening")),
187 Directive::Transaction(
189 Transaction::new(base_date, "Bitcoin purchase")
190 .with_flag('*')
191 .with_synthesized_posting(Posting::new(
192 "Assets:Crypto:BTC",
193 Amount::new(dec("0.00012345"), "BTC"),
194 ))
195 .with_synthesized_posting(Posting::new(
196 "Assets:Bank:Checking",
197 Amount::new(dec("-5.00"), "USD"),
198 )),
199 ),
200 Directive::Transaction(
202 Transaction::new(base_date, "High precision test")
203 .with_flag('*')
204 .with_synthesized_posting(Posting::new(
205 "Assets:Investments:Stock",
206 Amount::new(dec("1.1234567890123456"), "MICRO"),
207 ))
208 .with_synthesized_posting(Posting::auto("Equity:Opening")),
209 ),
210 Directive::Transaction(
212 Transaction::new(base_date, "Large number test")
213 .with_flag('*')
214 .with_synthesized_posting(Posting::new(
215 "Assets:Bank:Checking",
216 Amount::new(dec("999999999999.99"), "USD"),
217 ))
218 .with_synthesized_posting(Posting::auto("Equity:Opening")),
219 ),
220 Directive::Transaction(
222 Transaction::new(base_date, "Tiny amount")
223 .with_flag('*')
224 .with_synthesized_posting(Posting::new(
225 "Expenses:Test",
226 Amount::new(dec("0.00000001"), "USD"),
227 ))
228 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
229 ),
230 Directive::Transaction(
232 Transaction::new(base_date, "Trailing zeros")
233 .with_flag('*')
234 .with_synthesized_posting(Posting::new(
235 "Expenses:Test",
236 Amount::new(dec("100.10000"), "USD"),
237 ))
238 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
239 ),
240 Directive::Transaction(
242 Transaction::new(base_date, "Negative high precision")
243 .with_flag('*')
244 .with_synthesized_posting(Posting::new(
245 "Expenses:Test",
246 Amount::new(dec("-0.12345678"), "USD"),
247 ))
248 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
249 ),
250 Directive::Price(Price::new(
252 base_date,
253 "BTC",
254 Amount::new(dec("45678.12345678"), "USD"),
255 )),
256 ];
257
258 EdgeCaseCollection::new("decimals", directives)
259}
260
261pub fn generate_hierarchy_edge_cases() -> EdgeCaseCollection {
265 let base_date = crate::naive_date(2024, 1, 1).unwrap();
266 let open_date = base_date.yesterday().ok().unwrap();
267
268 let deep_asset = "Assets:Bank:Region:Country:City:Branch:Department:Team:SubTeam:Account";
270 let deep_expense = "Expenses:Category:SubCategory:Type:SubType:Detail:MoreDetail:Final";
271
272 let directives = vec![
273 Directive::Open(Open::new(open_date, deep_asset)),
275 Directive::Open(Open::new(open_date, deep_expense)),
276 Directive::Open(Open::new(open_date, "Assets:A:B:C:D:E:F:G:H:I:J")),
277 Directive::Open(Open::new(
278 open_date,
279 "Liabilities:Debt:Type:Lender:Account:SubAccount",
280 )),
281 Directive::Open(Open::new(open_date, "Equity:Opening")),
282 Directive::Transaction(
284 Transaction::new(base_date, "Deep hierarchy transfer")
285 .with_flag('*')
286 .with_synthesized_posting(Posting::new(
287 deep_expense,
288 Amount::new(dec("100.00"), "USD"),
289 ))
290 .with_synthesized_posting(Posting::auto(deep_asset)),
291 ),
292 Directive::Balance(Balance::new(
294 base_date,
295 deep_asset,
296 Amount::new(dec("-100.00"), "USD"),
297 )),
298 Directive::Pad(Pad::new(
300 base_date,
301 "Assets:A:B:C:D:E:F:G:H:I:J",
302 "Equity:Opening",
303 )),
304 ];
305
306 EdgeCaseCollection::new("hierarchy", directives)
307}
308
309pub fn generate_large_transaction_edge_cases() -> EdgeCaseCollection {
311 let base_date = crate::naive_date(2024, 1, 1).unwrap();
312 let open_date = base_date.yesterday().ok().unwrap();
313
314 let mut directives: Vec<Directive> = (0..25)
316 .map(|i| Directive::Open(Open::new(open_date, format!("Expenses:Category{i}"))))
317 .collect();
318
319 directives.push(Directive::Open(Open::new(
320 open_date,
321 "Assets:Bank:Checking",
322 )));
323
324 let mut txn =
326 Transaction::new(base_date, "Expense allocation with 20 categories").with_flag('*');
327
328 for i in 0..20 {
329 txn = txn.with_synthesized_posting(Posting::new(
330 format!("Expenses:Category{i}"),
331 Amount::new(dec("10.00"), "USD"),
332 ));
333 }
334
335 txn = txn.with_synthesized_posting(Posting::new(
337 "Assets:Bank:Checking",
338 Amount::new(dec("-200.00"), "USD"),
339 ));
340
341 directives.push(Directive::Transaction(txn));
342
343 let mut txn2 = Transaction::new(base_date, "Tagged transaction")
345 .with_flag('*')
346 .with_synthesized_posting(Posting::new(
347 "Expenses:Category0",
348 Amount::new(dec("50.00"), "USD"),
349 ))
350 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking"));
351
352 for i in 0..10 {
353 txn2 = txn2.with_tag(format!("tag{i}"));
354 }
355 for i in 0..10 {
356 txn2 = txn2.with_link(format!("link{i}"));
357 }
358
359 directives.push(Directive::Transaction(txn2));
360
361 EdgeCaseCollection::new("large-transactions", directives)
362}
363
364pub fn generate_boundary_date_edge_cases() -> EdgeCaseCollection {
366 let early_date = crate::naive_date(1900, 1, 1).unwrap();
368 let late_date = crate::naive_date(2099, 12, 31).unwrap();
370 let leap_date = crate::naive_date(2024, 2, 29).unwrap();
372 let end_jan = crate::naive_date(2024, 1, 31).unwrap();
374 let end_apr = crate::naive_date(2024, 4, 30).unwrap();
375
376 let directives = vec![
377 Directive::Open(Open::new(
379 crate::naive_date(1899, 12, 31).unwrap(),
380 "Assets:Historical:Account",
381 )),
382 Directive::Open(Open::new(
383 crate::naive_date(1899, 12, 31).unwrap(),
384 "Equity:Opening",
385 )),
386 Directive::Transaction(
388 Transaction::new(early_date, "Historical transaction from 1900")
389 .with_flag('*')
390 .with_synthesized_posting(Posting::new(
391 "Assets:Historical:Account",
392 Amount::new(dec("1.00"), "USD"),
393 ))
394 .with_synthesized_posting(Posting::auto("Equity:Opening")),
395 ),
396 Directive::Transaction(
398 Transaction::new(late_date, "Far future transaction")
399 .with_flag('*')
400 .with_synthesized_posting(Posting::new(
401 "Assets:Historical:Account",
402 Amount::new(dec("1000000.00"), "USD"),
403 ))
404 .with_synthesized_posting(Posting::auto("Equity:Opening")),
405 ),
406 Directive::Transaction(
408 Transaction::new(leap_date, "Leap day transaction")
409 .with_flag('*')
410 .with_synthesized_posting(Posting::new(
411 "Assets:Historical:Account",
412 Amount::new(dec("29.02"), "USD"),
413 ))
414 .with_synthesized_posting(Posting::auto("Equity:Opening")),
415 ),
416 Directive::Transaction(
418 Transaction::new(end_jan, "End of January")
419 .with_flag('*')
420 .with_synthesized_posting(Posting::new(
421 "Assets:Historical:Account",
422 Amount::new(dec("31.00"), "USD"),
423 ))
424 .with_synthesized_posting(Posting::auto("Equity:Opening")),
425 ),
426 Directive::Transaction(
427 Transaction::new(end_apr, "End of April")
428 .with_flag('*')
429 .with_synthesized_posting(Posting::new(
430 "Assets:Historical:Account",
431 Amount::new(dec("30.00"), "USD"),
432 ))
433 .with_synthesized_posting(Posting::auto("Equity:Opening")),
434 ),
435 ];
436
437 EdgeCaseCollection::new("boundary-dates", directives)
438}
439
440pub fn generate_special_character_edge_cases() -> EdgeCaseCollection {
444 let base_date = crate::naive_date(2024, 1, 1).unwrap();
445 let open_date = base_date.yesterday().ok().unwrap();
446
447 let directives = vec![
448 Directive::Open(Open::new(open_date, "Assets:Bank:Checking")),
449 Directive::Open(Open::new(open_date, "Expenses:Test")),
450 Directive::Transaction(
452 Transaction::new(base_date, "Purchase at Joe's Diner")
453 .with_flag('*')
454 .with_synthesized_posting(Posting::new(
455 "Expenses:Test",
456 Amount::new(dec("25.00"), "USD"),
457 ))
458 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
459 ),
460 Directive::Transaction(
462 Transaction::new(base_date, r"Path: C:\Users\Documents\file.txt")
463 .with_flag('*')
464 .with_synthesized_posting(Posting::new(
465 "Expenses:Test",
466 Amount::new(dec("10.00"), "USD"),
467 ))
468 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
469 ),
470 Directive::Transaction(
472 Transaction::new(base_date, "Multi-line description split across text")
473 .with_flag('*')
474 .with_synthesized_posting(Posting::new(
475 "Expenses:Test",
476 Amount::new(dec("5.00"), "USD"),
477 ))
478 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
479 ),
480 Directive::Transaction(
482 Transaction::new(base_date, "A".repeat(200))
483 .with_flag('*')
484 .with_synthesized_posting(Posting::new(
485 "Expenses:Test",
486 Amount::new(dec("1.00"), "USD"),
487 ))
488 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
489 ),
490 Directive::Transaction(
492 Transaction::new(base_date, "Regular narration")
493 .with_flag('*')
494 .with_payee("Company Name") .with_synthesized_posting(Posting::new(
496 "Expenses:Test",
497 Amount::new(dec("15.00"), "USD"),
498 ))
499 .with_synthesized_posting(Posting::auto("Assets:Bank:Checking")),
500 ),
501 ];
502
503 EdgeCaseCollection::new("special-characters", directives)
504}
505
506pub fn generate_minimal_edge_cases() -> EdgeCaseCollection {
510 let base_date = crate::naive_date(2024, 1, 1).unwrap();
511
512 let directives = vec![
513 Directive::Open(Open::new(base_date, "Assets:Minimal")),
515 Directive::Open(
517 Open::new(base_date, "Assets:WithCurrency").with_currencies(vec!["USD".into()]),
518 ),
519 Directive::Close(Close::new(
521 base_date.tomorrow().ok().unwrap(),
522 "Assets:Minimal",
523 )),
524 Directive::Commodity(Commodity::new(base_date, "MINI")),
526 Directive::Price(Price::new(
528 base_date,
529 "MINI",
530 Amount::new(dec("1.00"), "USD"),
531 )),
532 Directive::Note(Note::new(base_date, "Assets:WithCurrency", "A note")),
534 Directive::Event(Event::new(base_date, "type", "value")),
536 Directive::Transaction(
538 Transaction::new(base_date, "")
539 .with_flag('*')
540 .with_synthesized_posting(Posting::new(
541 "Assets:WithCurrency",
542 Amount::new(dec("0.00"), "USD"),
543 )),
544 ),
545 Directive::Transaction(
547 Transaction::new(base_date, "Auto-balanced")
548 .with_flag('*')
549 .with_synthesized_posting(Posting::new(
550 "Assets:WithCurrency",
551 Amount::new(dec("100.00"), "USD"),
552 ))
553 .with_synthesized_posting(Posting::auto("Assets:WithCurrency")),
554 ),
555 ];
556
557 EdgeCaseCollection::new("minimal", directives)
558}
559
560pub fn generate_all_edge_cases_beancount() -> String {
564 let mut output = String::new();
565 output.push_str("; Synthetic edge case beancount file\n");
566 output.push_str("; Generated by rustledger synthetic module\n\n");
567
568 for collection in generate_all_edge_cases() {
569 output.push_str(&format!(
570 "\n; === {} ===\n\n",
571 collection.category.to_uppercase()
572 ));
573 output.push_str(&collection.to_beancount());
574 }
575
576 output
577}
578
579fn dec(s: &str) -> Decimal {
581 Decimal::from_str(s).expect("Invalid decimal string")
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn test_generate_unicode_edge_cases() {
590 let collection = generate_unicode_edge_cases();
591 assert!(!collection.directives.is_empty());
592 assert_eq!(collection.category, "unicode");
593
594 let text = collection.to_beancount();
595 assert!(text.contains("Café"));
596 assert!(text.contains("東京"));
597 }
598
599 #[test]
600 fn test_generate_decimal_edge_cases() {
601 let collection = generate_decimal_edge_cases();
602 assert!(!collection.directives.is_empty());
603
604 let text = collection.to_beancount();
605 assert!(text.contains("0.00012345"));
606 }
607
608 #[test]
609 fn test_generate_all_edge_cases() {
610 let collections = generate_all_edge_cases();
611 assert!(!collections.is_empty());
612
613 let categories: Vec<_> = collections.iter().map(|c| c.category.as_str()).collect();
615 assert!(categories.contains(&"unicode"));
616 assert!(categories.contains(&"decimals"));
617 assert!(categories.contains(&"hierarchy"));
618 }
619
620 #[test]
621 fn test_generate_all_edge_cases_beancount() {
622 let text = generate_all_edge_cases_beancount();
623 assert!(text.contains("UNICODE"));
624 assert!(text.contains("DECIMALS"));
625 assert!(text.contains("HIERARCHY"));
626 }
627}