#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod error;
mod validators;
pub use error::{ErrorCode, Severity, ValidationError};
use validators::{
validate_balance, validate_close, validate_document, validate_note, validate_open,
validate_pad, validate_transaction,
};
use rayon::prelude::*;
use rustledger_core::NaiveDate;
const PARALLEL_SORT_THRESHOLD: usize = 5000;
use rust_decimal::Decimal;
use rustc_hash::{FxHashMap, FxHashSet};
use rustledger_core::{BookingMethod, Directive, InternedStr, Inventory};
use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
#[derive(Debug, Clone)]
struct AccountState {
opened: NaiveDate,
closed: Option<NaiveDate>,
currencies: FxHashSet<InternedStr>,
booking: BookingMethod,
}
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ValidationOptions {
pub require_commodities: bool,
pub check_documents: bool,
pub warn_future_dates: bool,
pub document_base: Option<std::path::PathBuf>,
pub document_dirs: Vec<std::path::PathBuf>,
pub account_types: Vec<String>,
pub infer_tolerance_from_cost: bool,
pub tolerance_multiplier: Decimal,
pub inferred_tolerance_default: FxHashMap<String, Decimal>,
}
impl Default for ValidationOptions {
fn default() -> Self {
Self {
require_commodities: false,
check_documents: true, warn_future_dates: false,
document_base: None,
document_dirs: Vec::new(),
account_types: vec![
"Assets".to_string(),
"Liabilities".to_string(),
"Equity".to_string(),
"Income".to_string(),
"Expenses".to_string(),
],
infer_tolerance_from_cost: false,
tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
}
}
}
impl ValidationOptions {
#[must_use]
pub fn with_account_types(mut self, types: Vec<String>) -> Self {
self.account_types = types;
self
}
#[must_use]
pub const fn with_require_commodities(mut self, require: bool) -> Self {
self.require_commodities = require;
self
}
#[must_use]
pub const fn with_check_documents(mut self, check: bool) -> Self {
self.check_documents = check;
self
}
#[must_use]
pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
self.warn_future_dates = warn;
self
}
#[must_use]
pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
self.document_dirs = dirs;
self
}
#[must_use]
pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
self.infer_tolerance_from_cost = infer;
self
}
#[must_use]
pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
self.tolerance_multiplier = multiplier;
self
}
#[must_use]
pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
self.inferred_tolerance_default = defaults;
self
}
}
#[derive(Debug, Clone)]
struct PendingPad {
source_account: InternedStr,
date: NaiveDate,
used: bool,
}
#[derive(Debug, Default)]
pub struct LedgerState {
accounts: FxHashMap<InternedStr, AccountState>,
inventories: FxHashMap<InternedStr, Inventory>,
commodities: FxHashSet<InternedStr>,
pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
options: ValidationOptions,
last_date: Option<NaiveDate>,
tolerances: FxHashMap<InternedStr, Decimal>,
}
impl LedgerState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_options(options: ValidationOptions) -> Self {
Self {
options,
..Default::default()
}
}
pub const fn set_require_commodities(&mut self, require: bool) {
self.options.require_commodities = require;
}
pub const fn set_check_documents(&mut self, check: bool) {
self.options.check_documents = check;
}
pub const fn set_warn_future_dates(&mut self, warn: bool) {
self.options.warn_future_dates = warn;
}
pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
self.options.document_base = Some(base.into());
}
#[must_use]
pub fn inventory(&self, account: &str) -> Option<&Inventory> {
self.inventories.get(account)
}
pub fn accounts(&self) -> impl Iterator<Item = &str> {
self.accounts.keys().map(InternedStr::as_str)
}
pub fn import_option_warnings(
&self,
warnings: &[(&str, &str)],
errors: &mut Vec<ValidationError>,
) {
for &(code, message) in warnings {
let error_code = match code {
"E7001" => ErrorCode::UnknownOption,
"E7002" => ErrorCode::InvalidOptionValue,
"E7003" => ErrorCode::DuplicateOption,
_ => continue,
};
errors.push(ValidationError::new(
error_code,
message.to_string(),
NaiveDate::default(),
));
}
}
}
pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
validate_with_options(directives, ValidationOptions::default())
}
pub fn validate_with_options(
directives: &[Directive],
options: ValidationOptions,
) -> Vec<ValidationError> {
let mut state = LedgerState::with_options(options);
let mut errors = Vec::new();
let today = jiff::Zoned::now().date();
let mut sorted: Vec<&Directive> = Vec::with_capacity(directives.len());
sorted.extend(directives.iter());
let sort_fn = |a: &&Directive, b: &&Directive| {
a.date()
.cmp(&b.date())
.then_with(|| a.priority().cmp(&b.priority()))
.then_with(|| a.has_cost_reduction().cmp(&b.has_cost_reduction()))
};
if sorted.len() >= PARALLEL_SORT_THRESHOLD {
sorted.par_sort_by(sort_fn);
} else {
sorted.sort_by(sort_fn);
}
for directive in sorted {
let date = directive.date();
if let Some(last) = state.last_date
&& date < last
{
errors.push(ValidationError::new(
ErrorCode::DateOutOfOrder,
format!("Directive date {date} is before previous directive {last}"),
date,
));
}
state.last_date = Some(date);
if state.options.warn_future_dates && date > today {
errors.push(ValidationError::new(
ErrorCode::FutureDate,
format!("Entry dated in the future: {date}"),
date,
));
}
match directive {
Directive::Open(open) => {
validate_open(&mut state, open, &mut errors);
}
Directive::Close(close) => {
validate_close(&mut state, close, &mut errors);
}
Directive::Transaction(txn) => {
validate_transaction(&mut state, txn, &mut errors);
}
Directive::Balance(bal) => {
validate_balance(&mut state, bal, &mut errors);
}
Directive::Commodity(comm) => {
state.commodities.insert(comm.currency.clone());
}
Directive::Pad(pad) => {
validate_pad(&mut state, pad, &mut errors);
}
Directive::Document(doc) => {
validate_document(&state, doc, &mut errors);
}
Directive::Note(note) => {
validate_note(&state, note, &mut errors);
}
_ => {}
}
}
for (target_account, pads) in &state.pending_pads {
for pad in pads {
if !pad.used {
errors.push(
ValidationError::new(
ErrorCode::PadWithoutBalance,
"Unused Pad entry".to_string(),
pad.date,
)
.with_context(format!(
" {} pad {} {}",
pad.date, target_account, pad.source_account
)),
);
}
}
}
errors
}
pub fn validate_spanned_with_options(
directives: &[Spanned<Directive>],
options: ValidationOptions,
) -> Vec<ValidationError> {
let mut state = LedgerState::with_options(options);
let mut errors = Vec::new();
let today = jiff::Zoned::now().date();
let mut sorted: Vec<&Spanned<Directive>> = Vec::with_capacity(directives.len());
sorted.extend(directives.iter());
let sort_fn = |a: &&Spanned<Directive>, b: &&Spanned<Directive>| {
a.value
.date()
.cmp(&b.value.date())
.then_with(|| a.value.priority().cmp(&b.value.priority()))
.then_with(|| {
a.value
.has_cost_reduction()
.cmp(&b.value.has_cost_reduction())
})
};
if sorted.len() >= PARALLEL_SORT_THRESHOLD {
sorted.par_sort_by(sort_fn);
} else {
sorted.sort_by(sort_fn);
}
for spanned in sorted {
let directive = &spanned.value;
let date = directive.date();
let error_count_before = errors.len();
if let Some(last) = state.last_date
&& date < last
{
errors.push(ValidationError::with_location(
ErrorCode::DateOutOfOrder,
format!("Directive date {date} is before previous directive {last}"),
date,
spanned,
));
}
state.last_date = Some(date);
if state.options.warn_future_dates && date > today {
errors.push(ValidationError::with_location(
ErrorCode::FutureDate,
format!("Entry dated in the future: {date}"),
date,
spanned,
));
}
match directive {
Directive::Open(open) => {
validate_open(&mut state, open, &mut errors);
}
Directive::Close(close) => {
validate_close(&mut state, close, &mut errors);
}
Directive::Transaction(txn) => {
validate_transaction(&mut state, txn, &mut errors);
}
Directive::Balance(bal) => {
validate_balance(&mut state, bal, &mut errors);
}
Directive::Commodity(comm) => {
state.commodities.insert(comm.currency.clone());
}
Directive::Pad(pad) => {
validate_pad(&mut state, pad, &mut errors);
}
Directive::Document(doc) => {
validate_document(&state, doc, &mut errors);
}
Directive::Note(note) => {
validate_note(&state, note, &mut errors);
}
_ => {}
}
for error in errors.iter_mut().skip(error_count_before) {
if error.span.is_none() {
error.span = Some(spanned.span);
error.file_id = Some(spanned.file_id);
}
if error.note.is_none() && spanned.file_id == SYNTHESIZED_FILE_ID {
error.note = Some(
"directive was synthesized by a plugin (no source location); \
check your `plugin \"…\"` declarations for the responsible plugin"
.to_string(),
);
}
}
}
for (target_account, pads) in &state.pending_pads {
for pad in pads {
if !pad.used {
errors.push(
ValidationError::new(
ErrorCode::PadWithoutBalance,
"Unused Pad entry".to_string(),
pad.date,
)
.with_context(format!(
" {} pad {} {}",
pad.date, target_account, pad.source_account
)),
);
}
}
}
errors
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use rustledger_core::{
Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
#[test]
fn test_validate_account_lifecycle() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Test")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100), "USD"),
)),
),
];
let errors = validate(&directives);
assert!(errors
.iter()
.any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
}
#[test]
fn test_validate_account_used_before_open() {
let directives = vec![
Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Test")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100), "USD"),
)),
),
Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
];
let errors = validate(&directives);
assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
}
#[test]
fn test_validate_account_used_after_close() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 7, 1), "Test")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
.with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
),
];
let errors = validate(&directives);
assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
}
#[test]
fn test_validate_balance_assertion() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000.00), "USD"),
)),
),
Directive::Balance(Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let errors = validate(&directives);
assert!(errors.is_empty(), "{errors:?}");
}
#[test]
fn test_validate_balance_assertion_failed() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000.00), "USD"),
)),
),
Directive::Balance(Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(500.00), "USD"), )),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::BalanceAssertionFailed)
);
}
#[test]
fn test_validate_balance_assertion_within_tolerance() {
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(70.538), "ABC"), ))
.with_posting(Posting::new(
"Expenses:Misc",
Amount::new(dec!(-70.538), "ABC"),
)),
),
Directive::Balance(Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(70.53), "ABC"), )),
];
let errors = validate(&directives);
assert!(
errors.is_empty(),
"Balance within tolerance should pass: {errors:?}"
);
}
#[test]
fn test_validate_balance_assertion_exceeds_tolerance() {
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(70.542), "ABC"),
))
.with_posting(Posting::new(
"Expenses:Misc",
Amount::new(dec!(-70.542), "ABC"),
)),
),
Directive::Balance(Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(70.53), "ABC"), )),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::BalanceAssertionFailed),
"Balance exceeding tolerance should fail"
);
}
#[test]
fn test_validate_unbalanced_transaction() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Unbalanced")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50.00), "USD"),
))
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(40.00), "USD"),
)), ),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::TransactionUnbalanced)
);
}
#[test]
fn test_validate_currency_not_allowed() {
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Test")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100.00), "EUR"),
)),
),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::CurrencyNotAllowed)
);
}
#[test]
fn test_validate_future_date_warning() {
let future_date = jiff::Zoned::now()
.date()
.checked_add(jiff::ToSpan::days(30))
.unwrap();
let directives = vec![Directive::Open(Open {
date: future_date,
account: "Assets:Bank".into(),
currencies: vec![],
booking: None,
meta: Default::default(),
})];
let errors = validate(&directives);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::FutureDate),
"Should not warn about future dates by default"
);
let options = ValidationOptions::default().with_warn_future_dates(true);
let errors = validate_with_options(&directives, options);
assert!(
errors.iter().any(|e| e.code == ErrorCode::FutureDate),
"Should warn about future dates when enabled"
);
}
#[test]
fn test_validate_document_not_found() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Bank".into(),
path: "/nonexistent/path/to/document.pdf".to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Should check documents by default"
);
let options = ValidationOptions::default().with_check_documents(false);
let errors = validate_with_options(&directives, options);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Should not report missing document when disabled"
);
}
#[test]
fn test_validate_document_account_not_open() {
let directives = vec![Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Unknown".into(),
path: "receipt.pdf".to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
})];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
"Should error for document on unopened account"
);
}
#[test]
fn test_validate_document_relative_path_in_document_dirs() {
let filename = "rustledger_test_889_relative_receipt.pdf";
let dir = tempfile::tempdir().unwrap();
let doc_subdir = dir.path().join("documents");
std::fs::create_dir_all(&doc_subdir).unwrap();
std::fs::write(doc_subdir.join(filename), "test").unwrap();
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Bank".into(),
path: filename.to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Should error when document_dirs not set"
);
let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
let errors = validate_with_options(&directives, options);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Should find document in document_dirs: {errors:?}"
);
}
#[test]
fn test_validate_document_relative_path_not_found_in_dirs() {
let filename = "rustledger_test_889_nonexistent.pdf";
let dir = tempfile::tempdir().unwrap();
let doc_subdir = dir.path().join("documents");
std::fs::create_dir_all(&doc_subdir).unwrap();
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Bank".into(),
path: filename.to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
];
let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
let errors = validate_with_options(&directives, options);
assert!(
errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Should error when file not found in any document_dir"
);
}
#[test]
fn test_validate_document_absolute_path_ignores_document_dirs() {
let filename = "rustledger_test_889_absolute_receipt.pdf";
let dir = tempfile::tempdir().unwrap();
let doc_subdir = dir.path().join("documents");
std::fs::create_dir_all(&doc_subdir).unwrap();
std::fs::write(doc_subdir.join(filename), "test").unwrap();
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Bank".into(),
path: doc_subdir.join(filename).display().to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
];
let options = ValidationOptions::default()
.with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
let errors = validate_with_options(&directives, options);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
"Absolute path should work even with wrong document_dirs: {errors:?}"
);
}
#[test]
fn test_error_code_is_warning() {
assert!(!ErrorCode::AccountNotOpen.is_warning());
assert!(!ErrorCode::DocumentNotFound.is_warning());
assert!(ErrorCode::FutureDate.is_warning());
}
#[test]
fn test_validate_pad_basic() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let errors = validate(&directives);
assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
}
#[test]
fn test_validate_pad_with_existing_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "Initial deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(500.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-500.00), "USD"),
)),
),
Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 15),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"), )),
];
let errors = validate(&directives);
assert!(
errors.is_empty(),
"Pad should add missing amount: {errors:?}"
);
}
#[test]
fn test_validate_pad_account_not_open() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
"Should error for pad on unopened account"
);
}
#[test]
fn test_validate_pad_source_not_open() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
];
let errors = validate(&directives);
assert!(
errors.iter().any(
|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
),
"Should error for pad with unopened source account"
);
}
#[test]
fn test_validate_pad_negative_adjustment() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "Big deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(2000.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-2000.00), "USD"),
)),
),
Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 15),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"), )),
];
let errors = validate(&directives);
assert!(
errors.is_empty(),
"Pad should handle negative adjustment: {errors:?}"
);
}
#[test]
fn test_validate_insufficient_units() {
use rustledger_core::CostSpec;
let cost_spec = CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD");
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(cost_spec.clone()),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Sell too many")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
.with_cost(cost_spec),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::InsufficientUnits),
"Should error for insufficient units: {errors:?}"
);
}
#[test]
fn test_validate_no_matching_lot() {
use rustledger_core::CostSpec;
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD"),
),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Sell at wrong price")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
CostSpec::empty()
.with_number_per(dec!(160))
.with_currency("USD"),
),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
),
];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
"Should error for no matching lot: {errors:?}"
);
}
#[test]
fn test_validate_multiple_lot_match_uses_fifo() {
use rustledger_core::CostSpec;
let cost_spec = CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD");
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy lot 1")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
),
Directive::Transaction(
Transaction::new(date(2024, 2, 15), "Buy lot 2")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
.with_cost(cost_spec),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
),
];
let errors = validate(&directives);
let booking_errors: Vec<_> = errors
.iter()
.filter(|e| {
matches!(
e.code,
ErrorCode::InsufficientUnits
| ErrorCode::NoMatchingLot
| ErrorCode::AmbiguousLotMatch
)
})
.collect();
assert!(
booking_errors.is_empty(),
"Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
);
}
#[test]
fn test_validate_successful_booking() {
use rustledger_core::CostSpec;
let cost_spec = CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD");
let directives = vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
.with_cost(cost_spec.clone()),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Sell")
.with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
.with_cost(cost_spec),
)
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
),
];
let errors = validate(&directives);
let booking_errors: Vec<_> = errors
.iter()
.filter(|e| {
matches!(
e.code,
ErrorCode::InsufficientUnits
| ErrorCode::NoMatchingLot
| ErrorCode::AmbiguousLotMatch
)
})
.collect();
assert!(
booking_errors.is_empty(),
"Should have no booking errors: {booking_errors:?}"
);
}
#[test]
fn test_validate_account_already_open() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::AccountAlreadyOpen),
"Should error for duplicate open: {errors:?}"
);
}
#[test]
fn test_validate_account_close_not_empty() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(100.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100.00), "USD"),
)),
),
Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
"Should warn for closing account with balance: {errors:?}"
);
}
#[test]
fn test_validate_no_postings_allowed() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
];
let errors = validate(&directives);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::NoPostings),
"Should NOT error for transaction with no postings: {errors:?}"
);
}
#[test]
fn test_validate_single_posting() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
)),
];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
"Should warn for transaction with single posting: {errors:?}"
);
assert!(ErrorCode::SinglePosting.is_warning());
}
#[test]
fn test_validate_single_posting_zero_cost_no_warning() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Grant").with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
rustledger_core::CostSpec::empty()
.with_number_per(dec!(0))
.with_currency("USD"),
),
),
),
];
let errors = validate(&directives);
assert!(
!errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
"Should NOT warn for zero-cost single posting: {errors:?}"
);
}
#[test]
fn test_validate_single_posting_nonzero_cost_still_warns() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy").with_posting(
Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
rustledger_core::CostSpec::empty()
.with_number_per(dec!(150))
.with_currency("USD"),
),
),
),
];
let errors = validate(&directives);
assert!(
errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
"Should warn for single posting with non-zero cost: {errors:?}"
);
}
#[test]
fn test_validate_pad_without_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::PadWithoutBalance),
"Should error for pad without subsequent balance: {errors:?}"
);
}
#[test]
fn test_validate_multiple_pads_for_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
date(2024, 1, 3),
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
)),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::MultiplePadForBalance),
"Should error for multiple pads before balance: {errors:?}"
);
}
#[test]
fn test_error_severity() {
assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
assert_eq!(
ErrorCode::AccountCloseNotEmpty.severity(),
Severity::Warning
);
assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
}
#[test]
fn test_validate_invalid_account_name() {
let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::InvalidAccountName),
"Should error for invalid account root: {errors:?}"
);
}
#[test]
fn test_validate_account_lowercase_component() {
let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::InvalidAccountName),
"Should error for lowercase component: {errors:?}"
);
}
#[test]
fn test_validate_valid_account_names() {
let valid_names = [
"Assets:Bank",
"Assets:Bank:Checking",
"Liabilities:CreditCard",
"Equity:Opening-Balances",
"Income:Salary2024",
"Expenses:Food:Restaurant",
"Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
for name in valid_names {
let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
let errors = validate(&directives);
let name_errors: Vec<_> = errors
.iter()
.filter(|e| e.code == ErrorCode::InvalidAccountName)
.collect();
assert!(
name_errors.is_empty(),
"Should accept valid account name '{name}': {name_errors:?}"
);
}
}
#[test]
fn test_e2002_balance_exceeds_explicit_tolerance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000.00), "USD"),
)),
),
Directive::Balance(
Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(999.00), "USD"),
)
.with_tolerance(dec!(0.01)),
),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
"Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
);
}
#[test]
fn test_e2002_balance_within_explicit_tolerance_passes() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000.00), "USD"),
))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000.00), "USD"),
)),
),
Directive::Balance(
Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(999.00), "USD"),
)
.with_tolerance(dec!(5.00)),
),
];
let errors = validate(&directives);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::BalanceToleranceExceeded
|| e.code == ErrorCode::BalanceAssertionFailed),
"Expected no balance errors, got: {errors:?}"
);
}
#[test]
fn test_e5001_undeclared_currency() {
use rustledger_core::Commodity;
let directives = vec![
Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(20.00), "EUR"), ))
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-20.00), "EUR"),
)),
),
];
let options = ValidationOptions::default().with_require_commodities(true);
let errors = validate_with_options(&directives, options);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::UndeclaredCurrency),
"Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
);
}
#[test]
fn test_e5001_declared_currency_passes() {
use rustledger_core::Commodity;
let directives = vec![
Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(20.00), "EUR"),
))
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-20.00), "EUR"),
)),
),
];
let options = ValidationOptions::default().with_require_commodities(true);
let errors = validate_with_options(&directives, options);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::UndeclaredCurrency),
"Expected no E5001 errors, got: {errors:?}"
);
}
#[test]
fn test_e5001_not_raised_without_require_commodities() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(20.00), "XYZ"), ))
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-20.00), "XYZ"),
)),
),
];
let errors = validate(&directives);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::UndeclaredCurrency),
"Should not raise E5001 without require_commodities, got: {errors:?}"
);
}
#[test]
fn test_e3002_multiple_missing_amounts() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50.00), "USD"),
))
.with_posting(Posting {
account: "Expenses:Food".into(),
units: None,
cost: None,
price: None,
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
})
.with_posting(Posting {
account: "Expenses:Drinks".into(),
units: None,
cost: None,
price: None,
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
}),
),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::MultipleInterpolation),
"Expected E3002 MultipleInterpolation, got: {errors:?}"
);
}
#[test]
fn test_e3002_single_missing_amount_ok() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50.00), "USD"),
))
.with_posting(Posting {
account: "Expenses:Food".into(),
units: None,
cost: None,
price: None,
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
}),
),
];
let errors = validate(&directives);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::MultipleInterpolation),
"Should not raise E3002 with single missing amount, got: {errors:?}"
);
}
#[test]
fn test_e7001_unknown_option() {
let state = LedgerState::new();
let mut errors = Vec::new();
state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, ErrorCode::UnknownOption);
assert!(errors[0].message.contains("bogus_option"));
}
#[test]
fn test_e7002_invalid_option_value() {
let state = LedgerState::new();
let mut errors = Vec::new();
state.import_option_warnings(
&[("E7002", "Invalid leaf account name: 'not-valid'")],
&mut errors,
);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
}
#[test]
fn test_e7003_duplicate_option() {
let state = LedgerState::new();
let mut errors = Vec::new();
state.import_option_warnings(
&[("E7003", "Option \"title\" can only be specified once")],
&mut errors,
);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
}
}