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, NaiveDate, Note, Open, Pad, Posting,
15    Price, Transaction,
16    format::{FormatConfig, format_directive},
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    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
53/// Generate all edge case collections.
54pub 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
66/// Generate Unicode edge cases.
67///
68/// Tests Unicode handling in:
69/// - Account names (via valid account segments)
70/// - Payee and narration strings
71/// - Metadata values
72/// - Event descriptions
73pub 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        // Open accounts first
79        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        // Transaction with Unicode in payee and narration
87        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        // Japanese characters
98        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        // Russian Cyrillic
109        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        // Arabic
120        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        // Emoji in narration
131        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        // Mixed scripts
141        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        // Note with Unicode
151        Directive::Note(Note::new(
152            base_date,
153            "Assets:Bank:Checking",
154            "Überprüfung der Kontoauszüge für März",
155        )),
156        // Event with Unicode
157        Directive::Event(Event::new(base_date, "location", "Zürich, Schweiz")),
158    ];
159
160    EdgeCaseCollection::new("unicode", directives)
161}
162
163/// Generate decimal precision edge cases.
164///
165/// Tests handling of:
166/// - High decimal precision (up to 20 decimal places)
167/// - Very large numbers
168/// - Very small numbers
169/// - Numbers with trailing zeros
170pub 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        // Open accounts first
176        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        // High precision (8 decimal places - crypto common)
182        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        // Very high precision (16 decimal places)
195        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        // Very large number
205        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        // Very small fractional amount
215        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        // Trailing zeros (should preserve precision)
225        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        // Negative with high precision
235        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        // Price with high precision
245        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
255/// Generate deep account hierarchy edge cases.
256///
257/// Tests parsing of accounts with many segments.
258pub 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    // Deep hierarchy accounts
263    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        // Open deep accounts
268        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        // Transaction with deep accounts
277        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        // Balance assertion on deep account
287        Directive::Balance(Balance::new(
288            base_date,
289            deep_asset,
290            Amount::new(dec("-100.00"), "USD"),
291        )),
292        // Pad with deep accounts
293        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
303/// Generate edge cases with large transactions (many postings).
304pub 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    // Generate open directives for accounts
309    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    // Create a transaction with 20 postings
319    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    // Add the balancing posting
330    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    // Transaction with many tags and links
338    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
358/// Generate boundary date edge cases.
359pub fn generate_boundary_date_edge_cases() -> EdgeCaseCollection {
360    // Early date (1900)
361    let early_date = NaiveDate::from_ymd_opt(1900, 1, 1).unwrap();
362    // Late date
363    let late_date = NaiveDate::from_ymd_opt(2099, 12, 31).unwrap();
364    // Leap year dates
365    let leap_date = NaiveDate::from_ymd_opt(2024, 2, 29).unwrap();
366    // End of months
367    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        // Open accounts at early date
372        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        // Early date transaction
381        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        // Late date transaction
391        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        // Leap year
401        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        // End of month
411        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
434/// Generate special character edge cases.
435///
436/// Tests handling of escaped characters and special strings.
437pub 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        // Quotes in narration (escaped)
445        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        // Backslash in narration
455        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        // Multi-line description split across text
465        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        // Long narration (200 characters)
475        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        // Special whitespace in payee
485        Directive::Transaction(
486            Transaction::new(base_date, "Regular narration")
487                .with_flag('*')
488                .with_payee("Company Name") // No tab - tabs are invalid in beancount
489                .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
500/// Generate minimal/empty edge cases.
501///
502/// Tests handling of minimal valid directives.
503pub fn generate_minimal_edge_cases() -> EdgeCaseCollection {
504    let base_date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
505
506    let directives = vec![
507        // Minimal open
508        Directive::Open(Open::new(base_date, "Assets:Minimal")),
509        // Open with currencies
510        Directive::Open(
511            Open::new(base_date, "Assets:WithCurrency").with_currencies(vec!["USD".into()]),
512        ),
513        // Close
514        Directive::Close(Close::new(base_date.succ_opt().unwrap(), "Assets:Minimal")),
515        // Minimal commodity
516        Directive::Commodity(Commodity::new(base_date, "MINI")),
517        // Minimal price
518        Directive::Price(Price::new(
519            base_date,
520            "MINI",
521            Amount::new(dec("1.00"), "USD"),
522        )),
523        // Minimal note
524        Directive::Note(Note::new(base_date, "Assets:WithCurrency", "A note")),
525        // Minimal event
526        Directive::Event(Event::new(base_date, "type", "value")),
527        // Transaction with empty narration
528        Directive::Transaction(Transaction::new(base_date, "").with_flag('*').with_posting(
529            Posting::new("Assets:WithCurrency", Amount::new(dec("0.00"), "USD")),
530        )),
531        // Transaction with only auto-balanced postings
532        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
546/// Generate a complete beancount file with all edge cases.
547///
548/// Returns a string containing all edge cases formatted as valid beancount.
549pub 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
565// Helper function to parse decimal from string
566fn 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        // Check all categories are present
600        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}