use rustledger_core::Directive;
use rustledger_parser::{ParseError, ParseErrorKind, ParseResult, parse, parse_directives};
fn parse_ok(source: &str) -> ParseResult {
let result = parse(source);
assert!(
result.errors.is_empty(),
"expected no errors, got: {:?}",
result.errors
);
result
}
fn count_directive_type(result: &ParseResult, type_name: &str) -> usize {
result
.directives
.iter()
.filter(|d| match &d.value {
Directive::Open(_) => type_name == "open",
Directive::Close(_) => type_name == "close",
Directive::Transaction(_) => type_name == "transaction",
Directive::Balance(_) => type_name == "balance",
Directive::Pad(_) => type_name == "pad",
Directive::Price(_) => type_name == "price",
Directive::Event(_) => type_name == "event",
Directive::Note(_) => type_name == "note",
Directive::Document(_) => type_name == "document",
Directive::Commodity(_) => type_name == "commodity",
Directive::Query(_) => type_name == "query",
Directive::Custom(_) => type_name == "custom",
})
.count()
}
#[test]
fn balance_tolerance_follows_an_arithmetic_amount() {
let forms = [
("100.00 USD", dec_str("100.00")),
("100.00 ~ 0.01 USD", dec_str("100.00")),
("100.00 USD ~ 0.01 USD", dec_str("100.00")),
("0.25 + 0.75 USD", dec_str("1.00")),
("0.25 + 0.75 ~ 0.01 USD", dec_str("1.00")),
("0.25 + 0.75 USD ~ 0.01 USD", dec_str("1.00")),
("(0.25 + 0.75) ~ 0.01 USD", dec_str("1.00")),
];
for (form, want) in forms {
let src = format!("2024-01-15 balance Assets:Cash {form}\n");
let result = rustledger_parser::parse(&src);
assert!(
result.errors.is_empty(),
"`{form}` must parse: {:?}",
result.errors,
);
let balance = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Balance(b) => Some(b),
_ => None,
})
.unwrap_or_else(|| panic!("`{form}` produced no balance directive"));
assert_eq!(
balance.amount.number, want,
"`{form}` evaluated to the wrong amount",
);
}
}
#[test]
fn a_price_rejects_a_tolerance_instead_of_discarding_it() {
for src in [
"2024-01-01 price USD 1.10 ~ 0.01 EUR\n",
"2024-01-01 price USD 1.10 + 0.05 ~ 0.01 EUR\n",
] {
let result = rustledger_parser::parse(src);
assert!(
result.errors.iter().any(|e| matches!(
&e.kind,
rustledger_parser::ParseErrorKind::SyntaxError(m)
if m.contains("a price has no tolerance")
)),
"`{src}` must be diagnosed, not silently truncated: {:?}",
result.errors,
);
assert!(
!result
.directives
.iter()
.any(|d| matches!(d.value, Directive::Price(_))),
"`{src}` must not also yield a price directive",
);
}
for (src, want) in [
("2024-01-01 price USD 1.10 EUR\n", dec_str("1.10")),
("2024-01-01 price USD 1.10 + 0.05 EUR\n", dec_str("1.15")),
("2024-01-01 price USD (1.10 + 0.05) EUR\n", dec_str("1.15")),
] {
let result = rustledger_parser::parse(src);
assert!(result.errors.is_empty(), "`{src}`: {:?}", result.errors);
let price = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Price(p) => Some(p),
_ => None,
})
.unwrap_or_else(|| panic!("`{src}` produced no price"));
assert_eq!(price.amount.number, want, "`{src}`");
}
}
#[test]
fn every_directive_either_keeps_tags_or_refuses_them() {
let refuses = [
("open", "2024-01-01 open Assets:A USD"),
("close", "2024-01-01 close Assets:A"),
("commodity", "2024-01-01 commodity USD"),
("event", "2024-01-01 event \"location\" \"NYC\""),
("price", "2024-01-01 price USD 1.10 EUR"),
("balance", "2024-01-01 balance Assets:A 0 USD"),
("pad", "2024-01-01 pad Assets:A Equity:O"),
("query", "2024-01-01 query \"n\" \"SELECT date\""),
];
for (name, header) in refuses {
for sigil in ["#atag", "^alink"] {
let src = format!("{header} {sigil}\n");
let result = rustledger_parser::parse(&src);
assert!(
!result.errors.is_empty(),
"`{name}` accepted {sigil} it has no field to hold",
);
}
let result = rustledger_parser::parse(&format!("{header}\n"));
assert!(
result.errors.is_empty(),
"`{name}` broke without a tag: {:?}",
result.errors,
);
}
let keeps = [
("note", "2024-01-01 note Assets:A \"n\" #atag ^alink\n"),
(
"document",
"2024-01-01 document Assets:A \"/x.pdf\" #atag ^alink\n",
),
(
"transaction",
"2024-01-01 * \"t\" #atag ^alink\n Assets:A 1 USD\n Equity:O\n",
),
];
for (name, src) in keeps {
let result = rustledger_parser::parse(src);
assert!(result.errors.is_empty(), "`{name}`: {:?}", result.errors);
let (tags, links) = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Note(n) => Some((n.tags.len(), n.links.len())),
Directive::Document(x) => Some((x.tags.len(), x.links.len())),
Directive::Transaction(t) => Some((t.tags.len(), t.links.len())),
_ => None,
})
.unwrap_or_else(|| panic!("`{name}` produced no directive"));
assert_eq!((tags, links), (1, 1), "`{name}` did not keep both");
}
}
#[test]
fn the_documented_tolerance_table_matches_behavior() {
let doc = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../docs/reference/compatibility.md"),
)
.expect("compatibility.md must be readable from the parser crate");
let section = doc
.split("### 6. Balance Tolerance Grammar")
.nth(1)
.and_then(|s| s.split("### 7.").next())
.expect("the tolerance section must exist under that heading");
let mut rows = 0;
for line in section.lines() {
let cells: Vec<&str> = line.split('|').map(str::trim).collect();
if cells.len() != 5 || !cells[1].starts_with('`') {
continue;
}
let form = cells[1].trim_matches('`');
let claim = cells[3].replace('*', "");
let claim = claim.trim();
let expect_ok = match claim {
"ok" | "accepted" | "ok (kept)" | "accepted (kept)" => true,
"diagnosed" => false,
other => panic!("unrecognized claim {other:?} for `{form}` -- add it here"),
};
let src = format!("2024-01-15 balance Assets:C {form}\n");
let result = rustledger_parser::parse(&src);
assert_eq!(
result.errors.is_empty(),
expect_ok,
"docs say `{form}` is {claim}, but parsing gave {:?}",
result.errors,
);
rows += 1;
}
assert!(
rows >= 8,
"only {rows} rows recognized in the documented table; the format \
probably changed and this test stopped checking anything",
);
}
#[test]
fn balance_tolerance_accepts_one_reading_and_diagnoses_none() {
let cases: &[(&str, bool, &str)] = &[
("1.00 USD", true, "ok"),
("1.00 ~ 0.01 USD", true, "ok"),
("0.25 + 0.75 ~ 0.01 USD", true, "ok"),
("(0.25 + 0.75) ~ 0.01 USD", true, "ok"),
("1.00 ~ 0.005 + 0.005 USD", true, "ok"),
("1.00 ~ 0.005 * 2 USD", true, "ok"),
("1.00 USD ~ 0.01 USD", true, "syntax error"),
("0.25 + 0.75 USD ~ 0.01 USD", true, "syntax error"),
("1.00 USD ~ 0.01 EUR", false, "syntax error"),
("1.00 ~ 0.001 0.02 USD", false, "syntax error"),
("1.00 ~ 0.01 ~ 0.02 USD", false, "syntax error"),
("1.00 USD ~", false, "syntax error"),
("1.00 ~ USD", false, "syntax error"),
];
for (form, accepted, bc) in cases {
let src = format!("2024-01-15 balance Assets:C {form}\n");
let result = rustledger_parser::parse(&src);
assert_eq!(
result.errors.is_empty(),
*accepted,
"`{form}` (beancount: {bc}) -- errors were {:?}",
result.errors,
);
}
let redundant = rustledger_parser::parse("2024-01-15 balance Assets:C 1.00 USD ~ 0.01 USD\n");
let canonical = rustledger_parser::parse("2024-01-15 balance Assets:C 1.00 ~ 0.01 USD\n");
let bal = |r: &rustledger_parser::ParseResult| {
r.directives
.iter()
.find_map(|d| match &d.value {
Directive::Balance(b) => Some((b.amount.clone(), b.tolerance)),
_ => None,
})
.expect("a balance")
};
assert_eq!(
bal(&redundant),
bal(&canonical),
"the redundant spelling must parse to the same amount and tolerance",
);
let msg_for = |form: &str| {
rustledger_parser::parse(&format!("2024-01-15 balance Assets:C {form}\n"))
.errors
.iter()
.find_map(|e| match &e.kind {
rustledger_parser::ParseErrorKind::SyntaxError(m) => Some(m.clone()),
_ => None,
})
.unwrap_or_default()
};
assert!(
msg_for("1.00 USD ~ 0.01 EUR").contains("same currency as the amount"),
"currency mismatch must say so: {}",
msg_for("1.00 USD ~ 0.01 EUR"),
);
assert!(
msg_for("1.00 ~ 0.001 0.02 USD").contains("Two numbers side by side"),
"juxtaposed numbers must say so: {}",
msg_for("1.00 ~ 0.001 0.02 USD"),
);
assert!(
msg_for("1.00 USD ~").contains("must be followed by a tolerance"),
"a dangling tilde must say so: {}",
msg_for("1.00 USD ~"),
);
assert!(
msg_for("1.00 ~ 0.01 ~ 0.02 USD").contains("one tolerance"),
"a second tilde must say so, not blame juxtaposed numbers: {}",
msg_for("1.00 ~ 0.01 ~ 0.02 USD"),
);
let dotted = rustledger_parser::parse("2024-01-15 balance Assets:C 1.00 ~ .005 + .005 USD\n");
assert!(
!dotted.errors.is_empty(),
"fixture must still be rejected for the real reason",
);
assert!(
!dotted.errors.iter().any(|e| matches!(
&e.kind,
rustledger_parser::ParseErrorKind::SyntaxError(m) if m.contains("Two numbers side by side")
)),
"must not add a tolerance complaint to input the lexer already refused: {:?}",
dotted.errors,
);
let arith = rustledger_parser::parse("2024-01-15 balance Assets:C 1.00 ~ 0.005 + 0.005 USD\n");
assert_eq!(
bal(&arith).1,
Some(dec_str("0.010")),
"`~ 0.005 + 0.005` must evaluate, not be rejected as two numbers",
);
}
fn dec_str(s: &str) -> rustledger_core::Decimal {
s.parse().expect("test decimal")
}
#[test]
fn test_parse_open_directive() {
let source = r"2024-01-01 open Assets:Bank:Checking USD, EUR";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "open"), 1);
if let Directive::Open(open) = &result.directives[0].value {
assert_eq!(open.account, "Assets:Bank:Checking");
assert_eq!(open.currencies, vec!["USD", "EUR"]);
} else {
panic!("expected open directive");
}
}
#[test]
fn test_parse_close_directive() {
let source = r"2024-12-31 close Assets:Bank:OldAccount";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "close"), 1);
if let Directive::Close(close) = &result.directives[0].value {
assert_eq!(close.account, "Assets:Bank:OldAccount");
} else {
panic!("expected close directive");
}
}
#[test]
fn test_parse_simple_transaction() {
let source = r#"
2024-01-15 * "Coffee Shop" "Morning coffee"
Expenses:Food:Coffee 5.00 USD
Assets:Cash
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(txn.payee.as_deref(), Some("Coffee Shop"));
assert_eq!(txn.narration.as_str(), "Morning coffee");
assert_eq!(txn.postings.len(), 2);
} else {
panic!("expected transaction");
}
}
#[test]
fn string_escapes_are_decoded() {
let source = "2024-01-15 * \"q=\\\"hi\\\" bs=\\\\ tab=\\t x=\\x\"\n \
Assets:Cash -5.00 USD\n Expenses:X 5.00 USD\n";
let result = parse_ok(source);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(txn.narration.as_str(), "q=\"hi\" bs=\\ tab=\t x=x");
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_transaction_with_tags_and_links() {
let source = r#"
2024-01-15 * "Dinner" #food #restaurant ^receipt-123
Expenses:Food 45.00 USD
Assets:Cash
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert!(txn.tags.iter().any(|t| t.as_str() == "food"));
assert!(txn.tags.iter().any(|t| t.as_str() == "restaurant"));
assert!(txn.links.iter().any(|l| l.as_str() == "receipt-123"));
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_balance_directive() {
let source = r"2024-01-31 balance Assets:Bank:Checking 1000.00 USD";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "balance"), 1);
if let Directive::Balance(bal) = &result.directives[0].value {
assert_eq!(bal.account, "Assets:Bank:Checking");
assert_eq!(bal.amount.number.to_string(), "1000.00");
assert_eq!(bal.amount.currency, "USD");
} else {
panic!("expected balance");
}
}
#[test]
fn test_parse_pad_directive() {
let source = r"2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "pad"), 1);
if let Directive::Pad(pad) = &result.directives[0].value {
assert_eq!(pad.account, "Assets:Bank:Checking");
assert_eq!(pad.source_account, "Equity:Opening-Balances");
} else {
panic!("expected pad");
}
}
#[test]
fn test_parse_price_directive() {
let source = r"2024-01-15 price AAPL 185.50 USD";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "price"), 1);
if let Directive::Price(price) = &result.directives[0].value {
assert_eq!(price.currency, "AAPL");
assert_eq!(price.amount.number.to_string(), "185.50");
assert_eq!(price.amount.currency, "USD");
} else {
panic!("expected price");
}
}
#[test]
fn test_parse_event_directive() {
let source = r#"2024-01-01 event "location" "New York""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "event"), 1);
if let Directive::Event(event) = &result.directives[0].value {
assert_eq!(event.event_type, "location");
assert_eq!(event.value, "New York");
} else {
panic!("expected event");
}
}
#[test]
fn test_parse_note_directive() {
let source = r#"2024-01-15 note Assets:Bank:Checking "Account review completed""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "note"), 1);
if let Directive::Note(note) = &result.directives[0].value {
assert_eq!(note.account, "Assets:Bank:Checking");
assert_eq!(note.comment, "Account review completed");
} else {
panic!("expected note");
}
}
#[test]
fn test_parse_document_directive() {
let source = r#"2024-01-15 document Assets:Bank:Checking "/path/to/statement.pdf""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "document"), 1);
if let Directive::Document(doc) = &result.directives[0].value {
assert_eq!(doc.account, "Assets:Bank:Checking");
assert_eq!(doc.path, "/path/to/statement.pdf");
} else {
panic!("expected document");
}
}
#[test]
fn test_parse_commodity_directive() {
let source = r#"2024-01-01 commodity USD
name: "US Dollar""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "commodity"), 1);
if let Directive::Commodity(comm) = &result.directives[0].value {
assert_eq!(comm.currency, "USD");
} else {
panic!("expected commodity");
}
}
#[test]
fn test_commodity_precision_metadata_round_trips() {
use rustledger_core::{FormatConfig, MetaValue, format_directives};
let source = "2024-01-01 commodity USD\n precision: 2\n";
let parsed = parse_ok(source);
let Directive::Commodity(comm) = &parsed.directives[0].value else {
panic!("expected commodity");
};
assert_eq!(
comm.meta.get("precision"),
Some(&MetaValue::Int(2)),
"parser must produce Int(2) for unquoted integer metadata"
);
let formatted = format_directives([&parsed.directives[0].value], &FormatConfig::default());
let reparsed = parse_ok(&formatted);
let Directive::Commodity(comm2) = &reparsed.directives[0].value else {
panic!("expected commodity after re-parse");
};
assert_eq!(
comm2.meta.get("precision"),
Some(&MetaValue::Int(2)),
"round-tripped precision must remain Int(2); got formatted: {formatted:?}"
);
}
#[test]
fn test_parse_query_directive() {
let source = r#"2024-01-01 query "expenses" "SELECT account, SUM(position)""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "query"), 1);
if let Directive::Query(q) = &result.directives[0].value {
assert_eq!(q.name, "expenses");
assert!(q.query.contains("SELECT"));
} else {
panic!("expected query");
}
}
#[test]
fn test_parse_custom_directive() {
let source = r#"2024-01-01 custom "budget" Expenses:Food 500.00 USD"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "custom"), 1);
}
#[test]
fn test_custom_directive_preserves_sign_and_tag_link() {
use rust_decimal_macros::dec;
use rustledger_core::{Amount, Currency, MetaValue};
let source = r#"2024-01-01 custom "budget" -50.00 USD TRUE"#;
let result = parse_ok(source);
let spanned = result
.directives
.iter()
.find(|d| matches!(d.value, Directive::Custom(_)))
.expect("expected a custom directive");
let Directive::Custom(custom) = &spanned.value else {
unreachable!()
};
assert_eq!(
custom.values,
vec![
MetaValue::Amount(Amount::new(dec!(-50.00), Currency::new("USD"))),
MetaValue::Bool(true),
],
"custom values: {:?}",
custom.values
);
}
#[test]
fn test_pushmeta_value_with_currency_is_amount() {
use rust_decimal_macros::dec;
use rustledger_core::{Amount, Currency, MetaValue};
let source = r"
pushmeta budget: 5 USD
2024-01-01 open Assets:Cash USD
popmeta budget:
";
let result = parse_ok(source);
let open = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Open(o) => Some(o),
_ => None,
})
.expect("expected an open directive");
assert_eq!(
open.meta.get("budget"),
Some(&MetaValue::Amount(Amount::new(
dec!(5),
Currency::new("USD")
))),
"pushmeta budget should be Amount(5 USD); meta: {:?}",
open.meta
);
}
#[test]
fn test_parse_options() {
let source = r#"
option "title" "My Ledger"
option "operating_currency" "USD"
option "operating_currency" "EUR"
"#;
let result = parse_ok(source);
assert_eq!(result.options.len(), 3);
assert_eq!(result.options[0].0, "title");
assert_eq!(result.options[0].1, "My Ledger");
}
#[test]
fn test_parse_includes() {
let source = r#"
include "accounts.beancount"
include "transactions/2024.beancount"
"#;
let result = parse_ok(source);
assert_eq!(result.includes.len(), 2);
assert_eq!(result.includes[0].0, "accounts.beancount");
assert_eq!(result.includes[1].0, "transactions/2024.beancount");
}
#[test]
fn test_parse_plugins() {
let source = r#"
plugin "beancount.plugins.leafonly"
plugin "beancount.plugins.check_commodity" "config_string"
"#;
let result = parse_ok(source);
assert_eq!(result.plugins.len(), 2);
assert_eq!(result.plugins[0].0, "beancount.plugins.leafonly");
assert!(result.plugins[0].1.is_none());
assert_eq!(result.plugins[1].0, "beancount.plugins.check_commodity");
assert_eq!(result.plugins[1].1, Some("config_string".to_string()));
}
#[test]
fn test_parse_transaction_with_cost() {
let source = r#"
2024-01-15 * "Buy stock"
Assets:Brokerage 10 AAPL {185.50 USD}
Assets:Cash -1855.00 USD
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
let posting = &txn.postings[0];
assert!(posting.cost.is_some());
let cost = posting.cost.as_deref().unwrap();
assert_eq!(
cost.number.unwrap().per_unit().unwrap().to_string(),
"185.50"
);
assert_eq!(cost.currency.as_deref(), Some("USD"));
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_transaction_with_price() {
let source = r#"
2024-01-15 * "Currency exchange"
Assets:USD 100.00 USD @ 0.85 EUR
Assets:EUR -85.00 EUR
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
let posting = &txn.postings[0];
assert!(posting.price.is_some());
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_transaction_with_total_cost() {
let source = r#"
2024-01-15 * "Buy stock with fees"
Assets:Brokerage 10 AAPL {{1860.00 USD}}
Assets:Cash -1860.00 USD
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
let posting = &txn.postings[0];
assert!(posting.cost.is_some());
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_transaction_with_metadata() {
let source = r#"
2024-01-15 * "Purchase"
receipt: "scan-001.pdf"
category: "office"
Expenses:Office 100.00 USD
item: "Printer paper"
Assets:Cash
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert!(txn.meta.contains_key("receipt"));
assert!(txn.meta.contains_key("category"));
assert!(txn.postings[0].meta.contains_key("item"));
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_boolean_metadata() {
let source = r#"
2024-01-15 * "Test"
recurring: TRUE
active: FALSE
enabled: True
disabled: False
Expenses:Test 100.00 USD
Assets:Cash
"#;
let result = parse_ok(source);
if let Directive::Transaction(txn) = &result.directives[0].value {
use rustledger_core::MetaValue;
assert_eq!(txn.meta.get("recurring"), Some(&MetaValue::Bool(true)));
assert_eq!(txn.meta.get("active"), Some(&MetaValue::Bool(false)));
assert_eq!(txn.meta.get("enabled"), Some(&MetaValue::Bool(true)));
assert_eq!(txn.meta.get("disabled"), Some(&MetaValue::Bool(false)));
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_extended_transaction_flags() {
for (flag, expected) in [
("P", 'P'), ("S", 'S'), ("T", 'T'), ("C", 'C'), ("U", 'U'), ("R", 'R'), ("M", 'M'), ("#", '#'), ("?", '?'), ] {
let source = format!(
r#"
2024-01-15 {flag} "Test transaction"
Expenses:Test 100 USD
Assets:Cash
"#
);
let result = parse_ok(&source);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(
txn.flag, expected,
"Flag {flag} should parse as '{expected}'"
);
} else {
panic!("expected transaction for flag {flag}");
}
}
}
#[test]
fn test_error_recovery_continues_parsing() {
let source = r"
2024-01-01 open Assets:Bank
; This line has an error
2024-01-15 invalid directive here
2024-01-31 close Assets:Bank
";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected parse errors");
assert!(
count_directive_type(&result, "open") >= 1,
"should have parsed open directive"
);
}
#[test]
fn test_error_on_invalid_date() {
let source = r"2024-13-45 open Assets:Bank";
let result = parse(source);
assert!(!result.errors.is_empty(), "expected error for invalid date");
}
#[test]
fn test_parse_single_digit_month() {
let source = "2024-1-15 open Assets:Checking\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"unexpected errors for single-digit month: {:?}",
result.errors
);
assert_eq!(count_directive_type(&result, "open"), 1);
if let Directive::Open(open) = &result.directives[0].value {
assert_eq!(open.date, rustledger_core::naive_date(2024, 1, 15).unwrap());
} else {
panic!("expected open directive");
}
}
#[test]
fn test_parse_single_digit_day() {
let source = "2024-01-5 open Assets:Cash USD\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"unexpected errors for single-digit day: {:?}",
result.errors
);
assert_eq!(count_directive_type(&result, "open"), 1);
}
#[test]
fn test_parse_single_digit_month_and_day() {
let source = "2024-1-1 open Assets:Cash USD\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"unexpected errors for single-digit month/day: {:?}",
result.errors
);
assert_eq!(count_directive_type(&result, "open"), 1);
if let Directive::Open(open) = &result.directives[0].value {
assert_eq!(open.date, rustledger_core::naive_date(2024, 1, 1).unwrap());
} else {
panic!("expected open directive");
}
}
#[test]
fn test_error_invalid_leap_year_date() {
let source = "2023-02-29 open Assets:Cash USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected error for invalid leap-year date"
);
let err = &result.errors[0];
assert!(
matches!(err.kind, ParseErrorKind::InvalidDateValue(_)),
"expected InvalidDateValue error kind, got: {:?}",
err.kind
);
let msg = err.message();
assert!(
msg.contains("day") && msg.contains("out of range"),
"expected error mentioning 'day' and 'out of range', got: '{msg}'"
);
assert!(
msg.contains("2023-02"),
"expected error mentioning '2023-02', got: '{msg}'"
);
}
#[test]
fn test_error_invalid_date_month_out_of_range() {
let source = "2024-13-01 open Assets:Cash USD\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected error for month out of range"
);
let err = &result.errors[0];
assert!(
matches!(err.kind, ParseErrorKind::InvalidDateValue(_)),
"expected InvalidDateValue error kind, got: {:?}",
err.kind
);
let msg = err.message();
assert!(
msg.contains("month") && msg.contains("out of range"),
"expected error mentioning 'month' and 'out of range', got: '{msg}'"
);
}
#[test]
fn test_error_on_invalid_account() {
let source = r"2024-01-01 open lowercase:invalid";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected error for invalid account"
);
}
#[test]
fn test_parse_empty_input() {
let result = parse("");
assert!(result.errors.is_empty());
assert!(result.directives.is_empty());
}
#[test]
fn test_parse_only_comments() {
let source = r"
; This is a comment
; Another comment
";
let result = parse_ok(source);
assert!(result.directives.is_empty());
assert_eq!(result.comments.len(), 2);
assert!(result.comments[0].value.contains("This is a comment"));
assert!(result.comments[1].value.contains("Another comment"));
}
#[test]
fn test_parse_comments_with_directives() {
let source = r#"
; Header comment
option "operating_currency" "USD"
; Section comment
2024-01-01 open Assets:Bank USD
description: "Main account"
; Footer comment
"#;
let result = parse_ok(source);
assert_eq!(result.directives.len(), 1);
assert_eq!(result.options.len(), 1);
assert_eq!(result.comments.len(), 3);
assert!(result.comments[0].value.contains("Header comment"));
assert!(result.comments[1].value.contains("Section comment"));
assert!(result.comments[2].value.contains("Footer comment"));
}
#[test]
fn test_parse_unicode_in_narration() {
let source = r#"2024-01-15 * "Café ☕" "Latte mit Milch"
Expenses:Food 5.00 EUR
Assets:Cash"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
if let Directive::Transaction(txn) = &result.directives[0].value {
assert_eq!(txn.payee.as_deref(), Some("Café ☕"));
assert_eq!(txn.narration.as_str(), "Latte mit Milch");
} else {
panic!("expected transaction");
}
}
#[test]
fn test_parse_negative_amounts() {
let source = r#"
2024-01-15 * "Refund"
Assets:Bank -50.00 USD
Expenses:Food
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "transaction"), 1);
}
#[test]
fn test_parse_large_numbers() {
let source = r"2024-01-15 price BTC 15000.00 USD";
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "price"), 1);
}
#[test]
fn test_parse_booking_method() {
let source = r#"2024-01-01 open Assets:Stock "FIFO""#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "open"), 1);
if let Directive::Open(open) = &result.directives[0].value {
assert_eq!(open.booking, Some("FIFO".to_string()));
} else {
panic!("expected open");
}
}
#[test]
fn test_parse_complete_ledger() {
let source = r#"
; Main ledger file
option "title" "Personal Finance"
option "operating_currency" "USD"
plugin "beancount.plugins.auto_accounts"
2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Assets:Bank:Savings USD
2024-01-01 open Expenses:Food
2024-01-01 open Expenses:Transport
2024-01-01 open Income:Salary
2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances
2024-01-15 * "Employer" "Monthly salary"
Income:Salary -5000.00 USD
Assets:Bank:Checking 5000.00 USD
2024-01-16 * "Grocery Store" "Weekly groceries" #food
Expenses:Food 150.00 USD
Assets:Bank:Checking
2024-01-17 * "Gas Station" "Fill up"
Expenses:Transport 45.00 USD
Assets:Bank:Checking
2024-01-31 balance Assets:Bank:Checking 4805.00 USD
2024-01-31 note Assets:Bank:Checking "Reconciled with bank statement"
"#;
let result = parse_ok(source);
assert_eq!(result.options.len(), 2);
assert_eq!(result.plugins.len(), 1);
assert_eq!(count_directive_type(&result, "open"), 5);
assert_eq!(count_directive_type(&result, "pad"), 1);
assert_eq!(count_directive_type(&result, "transaction"), 3);
assert_eq!(count_directive_type(&result, "balance"), 1);
assert_eq!(count_directive_type(&result, "note"), 1);
}
#[test]
fn test_parse_investment_ledger() {
let source = r#"
2024-01-01 open Assets:Brokerage AAPL, GOOG, USD
2024-01-01 open Income:Dividends
2024-01-01 open Income:Capital-Gains
2024-01-01 commodity AAPL
name: "Apple Inc."
2024-01-15 * "Buy Apple stock"
Assets:Brokerage 10 AAPL {185.00 USD, 2024-01-15}
Assets:Brokerage -1850.00 USD
2024-02-15 * "Receive dividend"
Assets:Brokerage 5.00 USD
Income:Dividends -5.00 USD
2024-03-15 price AAPL 190.00 USD
2024-04-15 * "Sell Apple stock"
Assets:Brokerage -5 AAPL {185.00 USD, 2024-01-15}
Assets:Brokerage 950.00 USD
Income:Capital-Gains -25.00 USD
"#;
let result = parse_ok(source);
assert_eq!(count_directive_type(&result, "open"), 3);
assert_eq!(count_directive_type(&result, "commodity"), 1);
assert_eq!(count_directive_type(&result, "transaction"), 3);
assert_eq!(count_directive_type(&result, "price"), 1);
}
#[test]
fn test_parse_directives_simple() {
let source = r#"
option "title" "Test"
2024-01-01 open Assets:Bank
"#;
let (directives, errors) = parse_directives(source);
assert!(errors.is_empty());
assert_eq!(directives.len(), 1);
}
#[test]
fn test_reject_leading_decimal() {
let source = "2024-01-15 * \"Test\"\n Expenses:Food .50 USD\n Assets:Checking\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for leading decimal amount '.50 USD'"
);
}
#[test]
fn test_accept_decimal_with_integer_part() {
let source = "2024-01-15 * \"Test\"\n Expenses:Food 0.50 USD\n Assets:Checking\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"valid amount '0.50 USD' should parse without errors, errors: {:?}",
result.errors
);
}
#[test]
fn test_reject_lowercase_booking_method() {
let source = "2024-01-01 open Assets:Stock AAPL \"fifo\"\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for lowercase booking method 'fifo'"
);
}
#[test]
fn test_accept_uppercase_booking_method() {
let source = "2024-01-01 open Assets:Stock AAPL \"FIFO\"\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"uppercase booking method 'FIFO' should be valid, errors: {:?}",
result.errors
);
}
#[test]
fn test_accept_strict_with_size_booking_method() {
let source = "2024-01-01 open Assets:Stock AAPL \"STRICT_WITH_SIZE\"\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"booking method 'STRICT_WITH_SIZE' should be valid, errors: {:?}",
result.errors
);
if let Directive::Open(open) = &result.directives[0].value {
assert_eq!(open.booking, Some("STRICT_WITH_SIZE".to_string()));
} else {
panic!("expected open directive");
}
}
#[test]
fn test_invalid_booking_method_error_includes_strict_with_size() {
let source = "2024-01-01 open Assets:Stock AAPL \"invalid_method\"\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for invalid booking method"
);
let error_msg = result.errors[0].message();
assert!(
error_msg.contains("STRICT_WITH_SIZE"),
"error message should list STRICT_WITH_SIZE as a valid method, got: {error_msg}"
);
}
#[test]
fn test_reject_uppercase_metadata_key() {
let source =
"2024-01-15 * \"Test\"\n Category: \"test\"\n Expenses:Food 50 USD\n Assets:Checking\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for metadata key starting with uppercase 'Category:'"
);
}
#[test]
fn test_reject_balance_without_amount() {
let source = "2024-01-15 balance Assets:Checking\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for balance directive without amount"
);
}
#[test]
fn test_reject_pad_without_source_account() {
let source = "2024-01-15 pad Assets:Checking\n";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for pad directive without source account"
);
}
#[test]
fn test_accept_unicode_account_name() {
let source = "2024-01-01 open Assets:銀行口座\n";
let result = parse(source);
assert!(
result.errors.is_empty(),
"Unicode account names should parse successfully, got: {:?}",
result
.errors
.iter()
.map(rustledger_parser::ParseError::message)
.collect::<Vec<_>>()
);
}
#[test]
fn test_reject_unclosed_cost_brace() {
let source = "\
2024-01-01 open Assets:Stock
2024-01-01 open Assets:Cash USD
2024-01-15 *
Assets:Stock 10 AAPL {150 USD
Assets:Cash -1500 USD
";
let result = parse(source);
assert!(
result
.errors
.iter()
.any(|e| e.message().contains("unclosed cost")),
"expected 'unclosed cost' parse error, got: {:?}",
result
.errors
.iter()
.map(ParseError::message)
.collect::<Vec<_>>()
);
}
#[test]
fn test_reject_incomplete_final_directive_at_eof() {
let source = "2024-01-01 open";
let result = parse(source);
assert!(
!result.errors.is_empty(),
"expected parse error for incomplete open directive at EOF, got: {:?}",
result
.errors
.iter()
.map(ParseError::message)
.collect::<Vec<_>>()
);
}
#[test]
fn test_cost_spec_n_hash_t_parses_compound() {
use rust_decimal_macros::dec;
use rustledger_core::CostNumber;
let source = "
2024-01-01 open Assets:Stock
2024-01-01 open Assets:Cash USD
2024-01-15 *
Assets:Stock 10 STK {50 # 1500 USD}
Assets:Cash -1500.00 USD
";
let result = parse_ok(source);
let txn = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Transaction(t) => Some(t),
_ => None,
})
.expect("transaction present");
let cost = txn.postings[0]
.value
.cost
.as_ref()
.expect("cost spec present");
assert_eq!(
cost.number,
Some(CostNumber::Compound {
per_unit: dec!(50),
total: dec!(1500)
}),
"the `#` form must carry BOTH components as written (#1700): \
beancount's compound_amount weighs N*per_unit + total"
);
assert_eq!(
cost.currency
.as_ref()
.map(rustledger_core::Currency::as_str),
Some("USD"),
"currency must still be captured after the `# T` clause"
);
}
#[test]
fn test_reject_unclosed_cost_brace_at_eof() {
let source = "\
2024-01-01 open Assets:Stock
2024-01-01 open Assets:Cash USD
2024-01-15 *
Assets:Stock 10 AAPL {150 USD";
let result = parse(source);
assert!(
result
.errors
.iter()
.any(|e| e.message().contains("unclosed cost")),
"expected 'unclosed cost' parse error at EOF, got: {:?}",
result
.errors
.iter()
.map(ParseError::message)
.collect::<Vec<_>>()
);
}
#[test]
fn parse_without_occurrences_skips_indices_but_matches_directives() {
let src = "2020-01-01 open Assets:Cash USD\n\
2020-02-01 * \"p\" \"m\"\n Assets:Cash 5.00 USD\n Income:Salary\n";
let full = rustledger_parser::parse(src);
let lean = rustledger_parser::parse_without_occurrences(src);
assert!(
!full.account_occurrences.is_empty() && !full.currency_occurrences.is_empty(),
"full parse should collect occurrences"
);
assert!(
lean.account_occurrences.is_empty() && lean.currency_occurrences.is_empty(),
"lean parse must skip occurrence collection"
);
assert_eq!(
format!("{:?}", full.directives),
format!("{:?}", lean.directives),
"directives must be identical"
);
assert_eq!(
format!("{:?}", full.errors),
format!("{:?}", lean.errors),
"errors must be identical"
);
assert_eq!(
format!("{:?}", full.options),
format!("{:?}", lean.options),
"options must be identical"
);
}
#[test]
fn account_names_accept_any_non_ascii_but_no_ascii_punctuation() {
for name in [
"Assets:CORP✨", "Assets:CORP½", "Assets:CORP→", "Assets:CORPé", "Assets:CORP、", ] {
assert!(
rustledger_parser::is_valid_account_name(name),
"{name} must be accepted (beancount accepts it)",
);
}
for name in ["Assets:A\u{a0}B", "Assets:A\u{2028}B", "Assets:A\u{200b}B"] {
assert!(
rustledger_parser::is_valid_account_name(name),
"{name:?} must be accepted (beancount accepts it)",
);
}
for name in [
"Assets:CORP_x", "Assets:CORP.x",
"Assets:CORP@x", "Assets:CORP{x", "Assets:corp✨", "Assets:✨x", ] {
assert!(
!rustledger_parser::is_valid_account_name(name),
"{name} must be rejected (beancount rejects it)",
);
}
}
#[test]
fn tags_and_links_are_rejected_only_where_beancount_rejects_them() {
let pre = "2018-01-01 open Assets:CORP\n2018-01-01 open Equity:Opening\n";
for (body, what) in [
("2018-06-01 open Assets:New #tag\n", "open/tag"),
("2018-06-01 open Assets:New ^lnk\n", "open/link"),
("2018-06-01 close Assets:CORP #tag\n", "close/tag"),
(
"2018-06-01 balance Assets:CORP 0.00 USD #tag\n",
"balance/tag",
),
("2018-06-01 commodity EUR #tag\n", "commodity/tag"),
("2018-06-01 event \"loc\" \"here\" #tag\n", "event/tag"),
("2018-06-01 price EUR 1.10 USD #tag\n", "price/tag"),
(
"2018-06-01 pad Assets:CORP Equity:Opening #tag\n",
"pad/tag",
),
] {
let parsed = rustledger_parser::parse(&format!("{pre}{body}"));
assert!(
!parsed.errors.is_empty(),
"{what}: must be a parse error (beancount rejects it)",
);
}
for (body, what) in [
("2018-06-01 note Assets:CORP \"n\" #tag\n", "note/tag"),
("2018-06-01 note Assets:CORP \"n\" ^lnk\n", "note/link"),
(
"2018-06-01 document Assets:CORP \"/tmp/x.pdf\" #tag\n",
"document/tag",
),
] {
let parsed = rustledger_parser::parse(&format!("{pre}{body}"));
assert!(
parsed.errors.is_empty(),
"{what}: must parse cleanly (beancount accepts it), got {:?}",
parsed.errors,
);
}
let parsed = rustledger_parser::parse("2018-01-01 open Assets:A\n category: #groceries\n");
assert!(
parsed.errors.is_empty(),
"a tag-valued metadata entry must still parse, got {:?}",
parsed.errors,
);
}
#[test]
fn metadata_keys_need_at_least_two_characters() {
let parse =
|key: &str| rustledger_parser::parse(&format!("2018-01-01 open Assets:A\n {key}: 42\n"));
assert!(
!parse("k").errors.is_empty(),
"a single-character key must be rejected (beancount: LexerError)",
);
for key in ["kk", "k1", "k-", "k_", "abc"] {
let parsed = parse(key);
assert!(
parsed.errors.is_empty(),
"{key}: must still be accepted (beancount accepts it), got {:?}",
parsed.errors,
);
}
assert!(
!parse("A").errors.is_empty(),
"an uppercase start must stay rejected",
);
}
#[test]
fn a_link_is_not_a_valid_metadata_value() {
let p = rustledger_parser::parse;
assert!(
!p("2018-01-01 open Assets:A\n ref: ^inv-1\n")
.errors
.is_empty(),
"a link as a metadata value must be rejected (beancount rejects it)",
);
for (src, what) in [
(
"2018-01-01 * \"t\" ^lnk\n cat: #groceries\n Assets:A 1.00 USD\n Assets:B -1.00 USD\n",
"tag as a metadata value, in a file that DOES contain a link",
),
(
"2018-01-01 open Assets:A\n cat: #groceries\n",
"tag as a metadata value",
),
(
"2018-01-01 * \"t\" ^inv-1\n Assets:A 1.00 USD\n Assets:B -1.00 USD\n",
"link on a TRANSACTION, where links belong",
),
(
"2018-01-01 * \"t\" #tag ^inv-1\n Assets:A 1.00 USD\n Assets:B -1.00 USD\n",
"tag and link together on a transaction",
),
] {
let parsed = p(src);
assert!(
parsed.errors.is_empty(),
"{what}: must still parse cleanly, got {:?}",
parsed.errors,
);
}
for src in [
"2018-01-01 open Assets:A\n ref: ^inv-1\n",
"2018-01-01 open Assets:A\n cat: #groceries\n",
"2018-01-01 * \"t\" ^inv-1\n Assets:A 1.00 USD\n Assets:B -1.00 USD\n",
] {
let green = rustledger_parser::parse(src);
let red = rustledger_parser::cst::parse_red_only(src);
assert_eq!(
green.errors.len(),
red.errors.len(),
"green/red error-count mismatch for {src}: {:?} vs {:?}",
green.errors,
red.errors,
);
}
}
#[test]
fn custom_and_pushmeta_reject_taglinks_by_their_own_rules() {
let p = rustledger_parser::parse;
for (src, what) in [
("2024-01-15 custom \"b\" ^link\n", "custom: link"),
("2024-01-15 custom \"b\" #tag\n", "custom: tag"),
(
"pushmeta ref: ^x\n2018-01-01 open Assets:A\npopmeta ref:\n",
"pushmeta: link",
),
] {
assert!(
!p(src).errors.is_empty(),
"{what}: must be rejected (beancount rejects it)"
);
}
for (src, what) in [
(
"pushmeta ref: #t\n2018-01-01 open Assets:A\npopmeta ref:\n",
"pushmeta: tag",
),
(
"2024-01-15 custom \"b\" \"s\" 42 TRUE\n",
"custom: string/number/bool",
),
("2024-01-15 custom \"b\" 2024-06-01\n", "custom: date"),
("2024-01-15 custom \"b\" Assets:A\n", "custom: account"),
(
"pushmeta ref: 42\n2018-01-01 open Assets:A\npopmeta ref:\n",
"pushmeta: number",
),
(
"2018-01-01 open Assets:A\n2018-01-01 open Assets:B\n\
2018-06-01 * \"t\" #tg ^lk\n Assets:A 1.00 USD\n Assets:B -1.00 USD\n",
"tag and link on a transaction",
),
] {
let parsed = p(src);
assert!(
parsed.errors.is_empty(),
"{what}: must parse cleanly, got {:?}",
parsed.errors
);
}
for src in [
"2024-01-15 custom \"b\" #tag\n",
"pushmeta ref: #t\n2018-01-01 open Assets:A\npopmeta ref:\n",
] {
let green = rustledger_parser::parse(src);
let red = rustledger_parser::cst::parse_red_only(src);
assert_eq!(
green.errors.len(),
red.errors.len(),
"green/red mismatch for {src}: {:?} vs {:?}",
green.errors,
red.errors
);
}
}
#[test]
fn note_and_document_keep_tags_after_a_blank_line() {
let src = "2024-01-01 open Assets:A USD\n\
2024-01-05 * \"t\"\n\
\x20 Assets:A 1.00 USD\n\
\x20 Equity:O\n\
\n\
2024-01-20 note Assets:A \"n\" #ntag ^nlink\n\
\n\
2024-01-21 document Assets:A \"/tmp/x.pdf\" #dtag ^dlink\n";
let result = rustledger_parser::parse(src);
let note = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Note(n) => Some(n),
_ => None,
})
.expect("fixture must yield a note");
assert_eq!(
note.tags
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["ntag"],
"a blank line before the note must not eat its tags"
);
assert_eq!(note.links.len(), 1, "nor its links");
let doc = result
.directives
.iter()
.find_map(|d| match &d.value {
Directive::Document(x) => Some(x),
_ => None,
})
.expect("fixture must yield a document");
assert_eq!(
doc.tags.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["dtag"],
"same for a document, which had this bug before notes existed"
);
assert_eq!(doc.links.len(), 1, "nor its links");
}
#[test]
fn note_preserves_tags_and_links() {
let src = "2024-01-01 open Assets:A USD\n\
2024-01-05 note Assets:A \"note text\" #ntag ^nlink\n\
2024-01-06 note Assets:A \"plain\"\n";
let result = rustledger_parser::parse(src);
assert!(result.errors.is_empty(), "fixture must parse clean");
let notes: Vec<&rustledger_core::Note> = result
.directives
.iter()
.filter_map(|d| match &d.value {
Directive::Note(n) => Some(n),
_ => None,
})
.collect();
assert_eq!(notes.len(), 2, "fixture must yield both notes");
let tagged = notes[0];
assert_eq!(
tagged
.tags
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["ntag"],
"a note's tags must survive parsing"
);
assert_eq!(
tagged
.links
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>(),
vec!["nlink"],
"a note's links must survive parsing"
);
assert!(notes[1].tags.is_empty() && notes[1].links.is_empty());
}