Skip to main content

rustledger_core/synthetic/
edge_cases.rs

1//! Edge case generators for stress-testing beancount parsers.
2//!
3//! These generators produce beancount directives that exercise edge cases
4//! in parsing and validation, such as:
5//!
6//! - Unicode in various positions
7//! - Extreme decimal precision
8//! - Deep account hierarchies
9//! - Large transactions with many postings
10//! - Boundary dates
11//! - Special characters
12
13use 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/// Collection of edge case directives grouped by category.
22#[derive(Debug, Clone)]
23pub struct EdgeCaseCollection {
24    /// Category name (e.g., "unicode", "decimals", "hierarchy")
25    pub category: String,
26    /// Directives in this collection
27    pub directives: Vec<Directive>,
28}
29
30impl EdgeCaseCollection {
31    /// Create a new edge case collection.
32    pub fn new(category: impl Into<String>, directives: Vec<Directive>) -> Self {
33        Self {
34            category: category.into(),
35            directives,
36        }
37    }
38
39    /// Format all directives to beancount text.
40    ///
41    /// All directives are aligned together against shared, file-wide column
42    /// widths, with a blank line separating each one.
43    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
59/// Generate all edge case collections.
60pub 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
72/// Generate Unicode edge cases.
73///
74/// Tests Unicode handling in:
75/// - Account names (via valid account segments)
76/// - Payee and narration strings
77/// - Metadata values
78/// - Event descriptions
79pub 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        // Open accounts first
85        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        // Transaction with Unicode in payee and narration
93        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        // Japanese characters
104        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        // Russian Cyrillic
115        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        // Arabic
126        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        // Emoji in narration
137        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        // Mixed scripts
147        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        // Note with Unicode
157        Directive::Note(Note::new(
158            base_date,
159            "Assets:Bank:Checking",
160            "Überprüfung der Kontoauszüge für März",
161        )),
162        // Event with Unicode
163        Directive::Event(Event::new(base_date, "location", "Zürich, Schweiz")),
164    ];
165
166    EdgeCaseCollection::new("unicode", directives)
167}
168
169/// Generate decimal precision edge cases.
170///
171/// Tests handling of:
172/// - High decimal precision (up to 20 decimal places)
173/// - Very large numbers
174/// - Very small numbers
175/// - Numbers with trailing zeros
176pub 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        // Open accounts first
182        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        // High precision (8 decimal places - crypto common)
188        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        // Very high precision (16 decimal places)
201        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        // Very large number
211        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        // Very small fractional amount
221        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        // Trailing zeros (should preserve precision)
231        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        // Negative with high precision
241        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        // Price with high precision
251        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
261/// Generate deep account hierarchy edge cases.
262///
263/// Tests parsing of accounts with many segments.
264pub 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    // Deep hierarchy accounts
269    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        // Open deep accounts
274        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        // Transaction with deep accounts
283        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        // Balance assertion on deep account
293        Directive::Balance(Balance::new(
294            base_date,
295            deep_asset,
296            Amount::new(dec("-100.00"), "USD"),
297        )),
298        // Pad with deep accounts
299        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
309/// Generate edge cases with large transactions (many postings).
310pub 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    // Generate open directives for accounts
315    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    // Create a transaction with 20 postings
325    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    // Add the balancing posting
336    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    // Transaction with many tags and links
344    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
364/// Generate boundary date edge cases.
365pub fn generate_boundary_date_edge_cases() -> EdgeCaseCollection {
366    // Early date (1900)
367    let early_date = crate::naive_date(1900, 1, 1).unwrap();
368    // Late date
369    let late_date = crate::naive_date(2099, 12, 31).unwrap();
370    // Leap year dates
371    let leap_date = crate::naive_date(2024, 2, 29).unwrap();
372    // End of months
373    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        // Open accounts at early date
378        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        // Early date transaction
387        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        // Late date transaction
397        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        // Leap year
407        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        // End of month
417        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
440/// Generate special character edge cases.
441///
442/// Tests handling of escaped characters and special strings.
443pub 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        // Quotes in narration (escaped)
451        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        // Backslash in narration
461        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        // Multi-line description split across text
471        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        // Long narration (200 characters)
481        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        // Special whitespace in payee
491        Directive::Transaction(
492            Transaction::new(base_date, "Regular narration")
493                .with_flag('*')
494                .with_payee("Company Name") // No tab - tabs are invalid in beancount
495                .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
506/// Generate minimal/empty edge cases.
507///
508/// Tests handling of minimal valid directives.
509pub fn generate_minimal_edge_cases() -> EdgeCaseCollection {
510    let base_date = crate::naive_date(2024, 1, 1).unwrap();
511
512    let directives = vec![
513        // Minimal open
514        Directive::Open(Open::new(base_date, "Assets:Minimal")),
515        // Open with currencies
516        Directive::Open(
517            Open::new(base_date, "Assets:WithCurrency").with_currencies(vec!["USD".into()]),
518        ),
519        // Close
520        Directive::Close(Close::new(
521            base_date.tomorrow().ok().unwrap(),
522            "Assets:Minimal",
523        )),
524        // Minimal commodity
525        Directive::Commodity(Commodity::new(base_date, "MINI")),
526        // Minimal price
527        Directive::Price(Price::new(
528            base_date,
529            "MINI",
530            Amount::new(dec("1.00"), "USD"),
531        )),
532        // Minimal note
533        Directive::Note(Note::new(base_date, "Assets:WithCurrency", "A note")),
534        // Minimal event
535        Directive::Event(Event::new(base_date, "type", "value")),
536        // Transaction with empty narration
537        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        // Transaction with only auto-balanced postings
546        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
560/// Generate a complete beancount file with all edge cases.
561///
562/// Returns a string containing all edge cases formatted as valid beancount.
563pub 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
579// Helper function to parse decimal from string
580fn 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        // Check all categories are present
614        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}