use proptest::prelude::*;
use rustledger_parser::cst::parse_red_only;
use rustledger_parser::parse;
const ACCOUNTS: &[&str] = &[
"Assets:Bank",
"Assets:Broker",
"Expenses:Food",
"Income:Salary",
"Liabilities:Card",
"Equity:Opening",
];
const CURRENCIES: &[&str] = &["USD", "EUR", "AAPL", "GBP"];
const FLAGS: &[&str] = &["*", "!", "txn"];
fn number() -> impl Strategy<Value = String> {
prop_oneof![
(1i64..10_000).prop_map(|n| n.to_string()),
(1i64..10_000).prop_map(|n| format!("-{n}")),
(1i64..1000, 0u32..3).prop_map(|(n, s)| format!(
"{n}.{:0width$}",
0,
width = s as usize + 1
)),
Just("0".to_owned()),
(1i64..999).prop_map(|n| format!("{n},000.00")),
(1i64..999).prop_map(|n| format!("+{n}.50")),
]
}
fn cost() -> impl Strategy<Value = String> {
prop_oneof![
Just(String::new()),
Just(" {}".to_owned()),
(number(), 0usize..CURRENCIES.len())
.prop_map(|(n, c)| format!(" {{{n} {}}}", CURRENCIES[c])),
(number(), 0usize..CURRENCIES.len())
.prop_map(|(n, c)| format!(" {{{{{n} {}}}}}", CURRENCIES[c])),
(number(), number(), 0usize..CURRENCIES.len())
.prop_map(|(a, b, c)| format!(" {{{a} # {b} {}}}", CURRENCIES[c])),
(number(), 0usize..CURRENCIES.len(), 1u32..28)
.prop_map(|(n, c, d)| format!(" {{{n} {}, 2024-01-{d:02}}}", CURRENCIES[c])),
(number(), 0usize..CURRENCIES.len())
.prop_map(|(n, c)| format!(" {{{n} {}, \"lot-a\"}}", CURRENCIES[c])),
Just(" {*}".to_owned()),
Just(" {2024-03-01}".to_owned()),
]
}
fn price() -> impl Strategy<Value = String> {
prop_oneof![
Just(String::new()),
(number(), 0usize..CURRENCIES.len()).prop_map(|(n, c)| format!(" @ {n} {}", CURRENCIES[c])),
(number(), 0usize..CURRENCIES.len())
.prop_map(|(n, c)| format!(" @@ {n} {}", CURRENCIES[c])),
Just(" @".to_owned()),
(number(), number(), 0usize..CURRENCIES.len())
.prop_map(|(a, b, c)| format!(" @ {a} + {b} {}", CURRENCIES[c])),
(number(), number(), 0usize..CURRENCIES.len())
.prop_map(|(a, b, c)| format!(" @@ {a} * {b} {}", CURRENCIES[c])),
(number(), number(), 0usize..CURRENCIES.len())
.prop_map(|(a, b, c)| format!(" @ ({a} - {b}) {}", CURRENCIES[c])),
]
}
fn posting() -> impl Strategy<Value = String> {
(
prop_oneof![Just(""), Just("! "), Just("* "), Just("# ")],
0usize..ACCOUNTS.len(),
prop::option::of((number(), 0usize..CURRENCIES.len())),
cost(),
price(),
)
.prop_map(|(flag, acct, units, cost, price)| match units {
Some((n, c)) => format!(
" {flag}{} {n} {}{cost}{price}",
ACCOUNTS[acct], CURRENCIES[c]
),
None => format!(" {flag}{}", ACCOUNTS[acct]),
})
}
fn tags_and_links() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
Just(" #trip".to_owned()),
Just(" #food".to_owned()),
Just(" ^invoice-1".to_owned()),
Just(" ^receipt".to_owned()),
],
0..3,
)
.prop_map(|v| v.concat())
}
fn fallback_line() -> impl Strategy<Value = String> {
prop_oneof![
Just(" note: \"m\"\n".to_owned()),
Just(" ; a body comment\n".to_owned()),
Just(" key: 42\n".to_owned()),
Just(" when: 2024-05-05\n".to_owned()),
Just(" Assets:Bank 5 USD + 3 USD\n".to_owned()),
Just(" Assets:Bank 5 USD 3 USD\n".to_owned()),
]
}
fn transaction() -> impl Strategy<Value = String> {
(
1u32..28,
0usize..FLAGS.len(),
prop::option::of(Just("\"Payee\"".to_owned())),
tags_and_links(),
prop::collection::vec(posting(), 1..4),
prop::collection::vec(fallback_line(), 0..2),
any::<bool>(),
)
.prop_map(|(day, flag, payee, tl, postings, extras, pipe)| {
let head = match (&payee, pipe) {
(Some(p), true) => format!("{p} | \"narration\""),
(Some(p), false) => format!("{p} \"narration\""),
(None, _) => "\"narration\"".to_owned(),
};
let mut s = format!("2024-01-{day:02} {} {head}{tl}\n", FLAGS[flag]);
for e in &extras {
s.push_str(e);
}
for p in &postings {
s.push_str(p);
s.push('\n');
}
s
})
}
fn other_directive() -> impl Strategy<Value = String> {
(
1u32..28,
0usize..ACCOUNTS.len(),
0usize..CURRENCIES.len(),
number(),
)
.prop_flat_map(|(d, a, c, n)| {
let (acct, cur) = (ACCOUNTS[a], CURRENCIES[c]);
prop_oneof![
Just(format!("2024-02-{d:02} open {acct}\n")),
Just(format!("2024-02-{d:02} open {acct} {cur}\n")),
Just(format!("2024-02-{d:02} open {acct} {cur} \"FIFO\"\n")),
Just(format!("2024-02-{d:02} close {acct}\n")),
Just(format!("2024-02-{d:02} balance {acct} {n} {cur}\n")),
Just(format!("2024-02-{d:02} balance {acct} {n} ~ 0.01 {cur}\n")),
Just(format!("2024-02-{d:02} price {cur} {n} USD\n")),
Just(format!("2024-02-{d:02} note {acct} \"a note\"\n")),
Just(format!("2024-02-{d:02} event \"loc\" \"here\"\n")),
Just(format!("2024-02-{d:02} document {acct} \"/x.pdf\"\n")),
Just(format!("2024-02-{d:02} commodity {cur}\n")),
Just(format!("2024-02-{d:02} pad {acct} Equity:Opening\n")),
Just(format!("2024-02-{d:02} custom \"budget\" \"x\" {n}\n")),
Just(format!("2024-02-{d:02} query \"q\" \"SELECT 1\"\n")),
Just("option \"title\" \"t\"\n".to_owned()),
Just("plugin \"beancount.plugins.auto\"\n".to_owned()),
Just("; a top-level comment\n".to_owned()),
Just("\n".to_owned()),
Just("garbage line\n".to_owned()),
Just("2024-13-99 * \"bad date\"\n Assets:Bank 1 USD\n".to_owned()),
]
})
}
fn ledger_source() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![3 => transaction(), 2 => other_directive()],
1..7,
)
.prop_map(|parts| parts.concat())
}
fn observables(r: &rustledger_parser::ParseResult) -> Vec<(&'static str, String)> {
vec![
("directives", format!("{:?}", r.directives)),
("errors", format!("{:?}", r.errors)),
("options", format!("{:?}", r.options)),
("comments", format!("{:?}", r.comments)),
(
"account_occurrences",
format!("{:?}", r.account_occurrences),
),
(
"currency_occurrences",
format!("{:?}", r.currency_occurrences),
),
("includes", format!("{:?}", r.includes)),
("plugins", format!("{:?}", r.plugins)),
("warnings", format!("{:?}", r.warnings)),
("has_leading_bom", format!("{:?}", r.has_leading_bom)),
("alignment", format!("{:?}", r.alignment())),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn green_equals_red_on_generated_ledgers(source in ledger_source()) {
let green = observables(&parse(&source));
let red = observables(&parse_red_only(&source));
for ((name, g), (_, r)) in green.iter().zip(red.iter()) {
prop_assert_eq!(
g, r,
"green vs red diverged on `{}` for source:\n{}",
name, source
);
}
}
#[test]
fn green_equals_red_with_a_leading_bom(source in ledger_source()) {
let source = format!("\u{feff}{source}");
let green = observables(&parse(&source));
let red = observables(&parse_red_only(&source));
for ((name, g), (_, r)) in green.iter().zip(red.iter()) {
prop_assert_eq!(
g, r,
"green vs red diverged on `{}` with a BOM for source:\n{}",
name, source
);
}
}
}
#[test]
fn the_generator_reaches_well_formed_transactions() {
use proptest::strategy::{Strategy, ValueTree};
use proptest::test_runner::TestRunner;
const N: u32 = 500;
let mut runner = TestRunner::deterministic();
let strategy = ledger_source();
let (mut parsed, mut clean_txns) = (0u32, 0u32);
for _ in 0..N {
let source = strategy.new_tree(&mut runner).expect("generates").current();
let r = parse(&source);
if !r.directives.is_empty() {
parsed += 1;
}
if r.errors.is_empty()
&& r.directives.iter().any(|d| {
matches!(&d.value, rustledger_core::Directive::Transaction(t)
if !t.postings.is_empty())
})
{
clean_txns += 1;
}
}
assert!(
parsed * 10 >= N * 9,
"generator should parse nearly always, got {parsed}/{N}"
);
assert!(
clean_txns * 4 >= N,
"at least a quarter of generated ledgers must be error-free \
transactions with postings — the shape the green converter is the \
only consumer of. Got {clean_txns}/{N}, so this suite would be \
exercising red-fallback and error recovery, which the byte fuzzer \
already covers."
);
}
#[test]
fn green_equals_red_on_the_node_shape_rules() {
let fixtures: &[(&str, &str)] = &[
(
"unclosed cost brace",
"2024-01-01 * \"t\"\n Assets:B 1 AAPL {100 USD\n",
),
(
"closed cost brace (control)",
"2024-01-01 * \"t\"\n Assets:B 1 AAPL {100 USD}\n Assets:C\n",
),
(
"closed double brace (control)",
"2024-01-01 * \"t\"\n Assets:B 1 AAPL {{100 USD}}\n Assets:C\n",
),
(
"unclosed double brace",
"2024-01-01 * \"t\"\n Assets:B 1 AAPL {{100 USD\n",
),
(
"empty cost component",
"2024-01-01 * \"t\"\n Assets:B 1 AAPL {100 USD,}\n Assets:C\n",
),
(
"link as metadata value",
"2024-01-01 * \"t\"\n key: ^alink\n Assets:B 1 USD\n Assets:C\n",
),
(
"tag as metadata value (valid)",
"2024-01-01 * \"t\"\n key: #atag\n Assets:B 1 USD\n Assets:C\n",
),
("link in custom", "2024-01-01 custom \"budget\" ^alink\n"),
("tag in custom", "2024-01-01 custom \"budget\" #atag\n"),
("link in pushmeta", "pushmeta key: ^alink\n"),
("tag in pushmeta (valid)", "pushmeta key: #atag\n"),
(
"all three rules in one file",
"2024-01-01 custom \"budget\" #atag ^alink\n\
pushmeta key: ^blink\n\
2024-01-03 * \"t\"\n key: ^clink\n Assets:B 1 AAPL {100 USD\n",
),
(
"BOM + all three",
"\u{feff}2024-01-01 custom \"budget\" ^alink\n\
2024-01-02 * \"t\"\n key: ^blink\n Assets:B 1 AAPL {100 USD\n",
),
(
"BOM + cost component shape",
"\u{feff}2024-01-01 * \"t\"\n Assets:B 1 AAPL {100 USD,}\n Assets:C\n",
),
];
for (label, source) in fixtures {
assert_eq!(
observables(&parse(source)),
observables(&parse_red_only(source)),
"green and red diverged on the {label} fixture",
);
}
let rule_hits = |needle: &str| {
fixtures
.iter()
.flat_map(|(_, s)| parse(s).errors)
.filter(|e| format!("{:?}", e.kind).contains(needle))
.count()
};
for (rule, needle, want) in [
("unclosed cost brace", "unclosed cost specification", 3),
("cost component shape", "cost-spec component", 1),
("link as metadata value", "not a valid metadata value", 2),
("custom value", "not a valid custom value", 3),
("pushmeta value", "not a valid pushmeta value", 2),
] {
let got = rule_hits(needle);
assert!(
got >= want,
"the {rule} rule fired {got} times across the fixtures, wanted at \
least {want} — these fixtures no longer exercise it, so this \
test is not comparing green against red on it",
);
}
}