#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod error;
mod validators;
pub use error::{ErrorCode, Severity, ValidationError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
Early,
Late,
}
use validators::{
validate_balance_early, validate_balance_late, validate_close, validate_close_late,
validate_document, validate_note, validate_open, validate_pad, validate_transaction_early,
validate_transaction_late,
};
use rayon::prelude::*;
use rustledger_core::NaiveDate;
const PARALLEL_SORT_THRESHOLD: usize = 5000;
const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
use rust_decimal::Decimal;
use rustc_hash::{FxHashMap, FxHashSet};
use rustledger_core::{BookingMethod, Commodity, 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,
padded_currencies: FxHashSet<InternedStr>,
}
#[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>,
pub(crate) late_close_processed: FxHashSet<(InternedStr, NaiveDate)>,
}
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(),
));
}
}
}
trait ValidatableDirective: Sync {
fn directive(&self) -> &Directive;
fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
}
impl ValidatableDirective for Directive {
fn directive(&self) -> &Directive {
self
}
fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
None
}
}
impl ValidatableDirective for Spanned<Directive> {
fn directive(&self) -> &Directive {
&self.value
}
fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
Some((self.span, self.file_id))
}
}
fn validate_phase_inner<D: ValidatableDirective>(
directives: &[D],
state: &mut LedgerState,
phase: Phase,
today: NaiveDate,
) -> Vec<ValidationError> {
let document_exists_cache = if phase == Phase::Early {
build_document_exists_cache(directives, &state.options)
} else {
FxHashMap::default()
};
if phase == Phase::Early {
state.last_date = None;
}
let mut errors = Vec::new();
let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
sorted.extend(directives.iter());
let sort_fn = |a: &&D, b: &&D| {
let ad = a.directive();
let bd = b.directive();
ad.date()
.cmp(&bd.date())
.then_with(|| ad.priority().cmp(&bd.priority()))
.then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
};
if sorted.len() >= PARALLEL_SORT_THRESHOLD {
sorted.par_sort_by(sort_fn);
} else {
sorted.sort_by(sort_fn);
}
for d in sorted {
let directive = d.directive();
let date = directive.date();
let error_count_before = errors.len();
if phase == Phase::Early {
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 (phase, directive) {
(Phase::Early, Directive::Open(open)) => {
validate_open(state, open, &mut errors);
}
(Phase::Early, Directive::Close(close)) => {
validate_close(state, close, &mut errors);
}
(Phase::Late, Directive::Close(close)) => {
validate_close_late(state, close, &mut errors);
}
(Phase::Early, Directive::Commodity(comm)) => {
state.commodities.insert(comm.currency.clone());
validate_commodity_precision_meta(comm, &mut errors);
}
(Phase::Early, Directive::Pad(pad)) => {
validate_pad(state, pad, &mut errors);
}
(Phase::Early, Directive::Document(doc)) => {
validate_document(state, doc, &document_exists_cache, &mut errors);
}
(Phase::Early, Directive::Note(note)) => {
validate_note(state, note, &mut errors);
}
(Phase::Early, Directive::Transaction(txn)) => {
validate_transaction_early(state, txn, &mut errors);
}
(Phase::Late, Directive::Transaction(txn)) => {
validate_transaction_late(state, txn, &mut errors);
}
(Phase::Early, Directive::Balance(bal)) => {
validate_balance_early(state, bal, &mut errors);
}
(Phase::Late, Directive::Balance(bal)) => {
validate_balance_late(state, bal, &mut errors);
}
_ => {}
}
if let Some((span, file_id)) = d.span_info() {
for error in errors.iter_mut().skip(error_count_before) {
if error.span.is_none() {
error.span = Some(span);
error.file_id = Some(file_id);
}
if error.note.is_none() && 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(),
);
}
}
}
}
errors
}
fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
let mut errors = Vec::new();
for (target_account, pads) in &state.pending_pads {
for pad in pads {
if pad.padded_currencies.is_empty() {
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
}
fn build_document_exists_cache<D: ValidatableDirective>(
directives: &[D],
options: &ValidationOptions,
) -> FxHashMap<String, bool> {
if !options.check_documents {
return FxHashMap::default();
}
let mut paths: FxHashSet<&str> = FxHashSet::default();
for d in directives {
if let Directive::Document(doc) = d.directive() {
paths.insert(doc.path.as_str());
}
}
let paths: Vec<&str> = paths.into_iter().collect();
let resolve = |s: &str| -> (String, bool) {
let doc_path = std::path::Path::new(s);
let found = if doc_path.is_absolute() {
doc_path.exists()
} else if let Some(base) = &options.document_base {
base.join(doc_path).exists()
} else if !options.document_dirs.is_empty() {
options
.document_dirs
.iter()
.any(|dir| dir.join(doc_path).exists())
} else {
doc_path.exists()
};
(s.to_string(), found)
};
if paths.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
paths.into_par_iter().map(resolve).collect()
} else {
paths.into_iter().map(resolve).collect()
}
}
pub struct ValidationSession {
state: LedgerState,
phases_run: u8,
}
impl ValidationSession {
const PHASE_EARLY_BIT: u8 = 1 << 0;
const PHASE_LATE_BIT: u8 = 1 << 1;
#[must_use]
pub fn new(options: ValidationOptions) -> Self {
Self {
state: LedgerState::with_options(options),
phases_run: 0,
}
}
pub fn run_phase(
&mut self,
directives: &[Directive],
phase: Phase,
today: NaiveDate,
) -> Vec<ValidationError> {
if !self.check_phase_ordering(phase) {
return Vec::new();
}
validate_phase_inner(directives, &mut self.state, phase, today)
}
pub fn run_phase_spanned(
&mut self,
directives: &[Spanned<Directive>],
phase: Phase,
today: NaiveDate,
) -> Vec<ValidationError> {
if !self.check_phase_ordering(phase) {
return Vec::new();
}
validate_phase_inner(directives, &mut self.state, phase, today)
}
#[must_use]
pub fn finalize(self) -> Vec<ValidationError> {
check_unused_pads(&self.state)
}
fn check_phase_ordering(&mut self, phase: Phase) -> bool {
let bit = match phase {
Phase::Early => Self::PHASE_EARLY_BIT,
Phase::Late => Self::PHASE_LATE_BIT,
};
if self.phases_run & bit != 0 {
debug_assert!(
false,
"ValidationSession::run_phase{{,_spanned}} called twice for {phase:?}; \
each phase must run exactly once per session"
);
return false;
}
if matches!(phase, Phase::Late) && self.phases_run & Self::PHASE_EARLY_BIT == 0 {
debug_assert!(
false,
"ValidationSession::run_phase{{,_spanned}}(Phase::Late) called before Phase::Early; \
Late depends on state Early builds (open accounts, commodities, pending pads)"
);
return false;
}
self.phases_run |= bit;
true
}
}
fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
let Some(value) = comm.meta.get("precision") else {
return;
};
if let Err(reason) = rustledger_core::parse_precision_meta(value) {
errors.push(ValidationError::new(
ErrorCode::InvalidPrecisionMetadata,
format!(
"invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
comm.currency
),
comm.date,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
use rustledger_core::{
Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
};
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
fn test_today() -> NaiveDate {
date(2030, 1, 1)
}
fn validate(directives: &[Directive]) -> Vec<ValidationError> {
validate_with_options(directives, ValidationOptions::default())
}
fn validate_with_options(
directives: &[Directive],
options: ValidationOptions,
) -> Vec<ValidationError> {
validate_with_today(directives, options, test_today())
}
fn validate_with_today(
directives: &[Directive],
options: ValidationOptions,
today: NaiveDate,
) -> Vec<ValidationError> {
let mut session = ValidationSession::new(options);
let mut errors = session.run_phase(directives, Phase::Early, today);
errors.extend(session.run_phase(directives, Phase::Late, today));
errors.extend(session.finalize());
errors
}
#[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 today = date(2024, 1, 1);
let future_date = today.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_with_today(&directives, ValidationOptions::default(), today);
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_today(&directives, options, today);
assert!(
errors.iter().any(|e| e.code == ErrorCode::FutureDate),
"Should warn about future dates when enabled"
);
}
#[test]
fn test_validate_with_today_threads_today_parameter() {
let directives = vec![Directive::Open(Open {
date: date(2024, 6, 15),
account: "Assets:Bank".into(),
currencies: vec![],
booking: None,
meta: Default::default(),
})];
let options = ValidationOptions::default().with_warn_future_dates(true);
let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
assert!(
errors.iter().any(|e| e.code == ErrorCode::FutureDate),
"with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
);
let errors = validate_with_today(&directives, options, date(2025, 1, 1));
assert!(
!errors.iter().any(|e| e.code == ErrorCode::FutureDate),
"with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
);
}
#[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_validate_document_parallel_batch_check() {
let dir = tempfile::tempdir().unwrap();
let doc_subdir = dir.path().join("docs");
std::fs::create_dir_all(&doc_subdir).unwrap();
let mut directives: Vec<Directive> =
vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
for i in 0..100 {
let filename = format!("receipt_{i}.pdf");
if i % 2 == 0 {
std::fs::write(doc_subdir.join(&filename), "x").unwrap();
}
directives.push(Directive::Document(Document {
date: date(2024, 1, 15),
account: "Assets:Bank".into(),
path: filename,
tags: vec![],
links: vec![],
meta: Default::default(),
}));
}
let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
let errors = validate_with_options(&directives, options);
let not_found_count = errors
.iter()
.filter(|e| e.code == ErrorCode::DocumentNotFound)
.count();
assert_eq!(
not_found_count, 50,
"exactly 50 of 100 documents should error as not-found"
);
let example = errors
.iter()
.find(|e| e.code == ErrorCode::DocumentNotFound)
.expect("should have at least one not-found error");
assert!(
example
.context
.as_deref()
.is_some_and(|c| c.contains("searched")),
"error context should mention the searched dirs, got: {:?}",
example.context
);
}
#[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_e2004_fires_after_prior_balance_consumed_a_pad() {
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!(100.00), "USD"),
)),
Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 2, 3),
"Assets:Bank",
Amount::new(dec!(200.00), "USD"),
)),
];
let errors = validate(&directives);
let multi_pad_count = errors
.iter()
.filter(|e| e.code == ErrorCode::MultiplePadForBalance)
.count();
assert_eq!(
multi_pad_count, 1,
"E2004 must fire exactly once on the second balance; got {errors:?}"
);
}
#[test]
fn test_pad_serves_multi_currency_balances_on_same_day() {
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!(100.00), "USD"),
)),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(50.00), "EUR"),
)),
];
let errors = validate(&directives);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::BalanceAssertionFailed),
"pad should serve both USD and EUR; got {errors:?}"
);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::PadWithoutBalance),
"pad serves at least one balance; should not be E2003; got {errors:?}"
);
}
#[test]
fn test_same_day_pad_does_not_apply_to_same_day_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, 2), "Assets:Bank", "Equity:Opening")),
Directive::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(100.00), "USD"),
)),
];
let errors = validate(&directives);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::BalanceAssertionFailed),
"same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::PadWithoutBalance),
"same-day pad never consumed; expected E2003; got {errors:?}"
);
}
#[test]
fn test_future_pad_does_not_apply_to_earlier_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::Balance(Balance::new(
date(2024, 1, 2),
"Assets:Bank",
Amount::new(dec!(0.00), "USD"),
)),
Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
];
let errors = validate(&directives);
assert!(
!errors
.iter()
.any(|e| e.code == ErrorCode::BalanceAssertionFailed),
"future pad should not influence earlier balance; got {errors:?}"
);
assert!(
errors
.iter()
.any(|e| e.code == ErrorCode::PadWithoutBalance),
"future-dated pad without subsequent balance should fire E2003; got {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);
}
fn commodity_with_precision(value: MetaValue) -> Directive {
let mut meta = rustledger_core::Metadata::default();
meta.insert("precision".into(), value);
Directive::Commodity(
rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
)
}
#[test]
fn precision_meta_valid_integer_emits_no_warning() {
let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
let errors = validate(&directives);
assert!(
errors
.iter()
.all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
"valid precision must not produce a warning, got: {errors:?}"
);
}
#[test]
fn precision_meta_zero_is_valid() {
let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
let errors = validate(&directives);
assert!(
errors
.iter()
.all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
);
}
#[test]
fn precision_meta_negative_emits_e5003() {
let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
let errors = validate(&directives);
let warnings: Vec<_> = errors
.iter()
.filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
.collect();
assert_eq!(warnings.len(), 1, "expected one E5003");
assert_eq!(warnings[0].code.severity(), Severity::Warning);
assert!(warnings[0].message.contains("non-negative"));
}
#[test]
fn precision_meta_non_integer_emits_e5003() {
let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
let errors = validate(&directives);
let warnings: Vec<_> = errors
.iter()
.filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
.collect();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("integer"));
}
#[test]
fn precision_meta_string_value_emits_e5003() {
let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
let errors = validate(&directives);
let warnings: Vec<_> = errors
.iter()
.filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
.collect();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("string"));
}
#[test]
fn precision_meta_out_of_u32_range_emits_e5003() {
let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
8589934592
)))];
let errors = validate(&directives);
let warnings: Vec<_> = errors
.iter()
.filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
.collect();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("exceeds"));
}
#[test]
fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
let directives = vec![
commodity_with_precision(MetaValue::Number(dec!(2))),
commodity_with_precision(MetaValue::Number(dec!(-1))),
];
let warnings: Vec<_> = validate(&directives)
.into_iter()
.filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
.collect();
assert_eq!(
warnings.len(),
1,
"exactly one E5003 expected (only the invalid declaration)"
);
assert!(warnings[0].message.contains("non-negative"));
}
#[test]
fn precision_meta_e5003_is_warning_severity() {
assert_eq!(
ErrorCode::InvalidPrecisionMetadata.severity(),
Severity::Warning
);
assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
}
#[test]
fn test_validate_early_emits_e1001_on_elided_posting() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Zero to unopened")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(0.00), "USD")))
.with_posting(Posting::auto("Expenses:NeverOpened")),
),
];
let mut session = ValidationSession::new(ValidationOptions::default());
let errors = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
assert!(
errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
&& e.to_string().contains("Expenses:NeverOpened")),
"early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
);
}
#[test]
fn test_validate_late_does_not_duplicate_e1001() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "To unopened")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
.with_posting(Posting::new(
"Expenses:NeverOpened",
Amount::new(dec!(-100), "USD"),
)),
),
];
let mut session = ValidationSession::new(ValidationOptions::default());
let early = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
let late = session.run_phase(&directives, Phase::Late, date(2026, 1, 1));
let early_e1001 = early
.iter()
.filter(|e| e.code == ErrorCode::AccountNotOpen)
.count();
let late_e1001 = late
.iter()
.filter(|e| e.code == ErrorCode::AccountNotOpen)
.count();
assert_eq!(early_e1001, 1, "early phase should emit E1001 once");
assert_eq!(
late_e1001, 0,
"late phase must not re-emit account-presence errors; got: {late:?}"
);
}
#[test]
fn test_validate_chained_matches_explicit_phases() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Mixed")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(50), "USD")))
.with_posting(Posting::new("Income:Salary", Amount::new(dec!(-50), "USD"))),
),
Directive::Balance(Balance::new(
date(2024, 1, 16),
"Assets:Bank",
Amount::new(dec!(50), "USD"),
)),
];
let chained = validate(&directives);
let mut session = ValidationSession::new(ValidationOptions::default());
let mut explicit = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
explicit.extend(session.run_phase(&directives, Phase::Late, date(2026, 1, 1)));
explicit.extend(session.finalize());
let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
assert_eq!(
chained_strs, explicit_strs,
"legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
);
}
#[test]
fn test_phase_order_early_then_late_then_finalize() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 1, 5), "early")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
.with_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100), "USD"),
)),
),
Directive::Pad(Pad::new(
date(2024, 1, 10),
"Assets:Other",
"Equity:Opening",
)),
Directive::Balance(Balance::new(
date(2024, 2, 1),
"Assets:Bank",
Amount::new(dec!(999), "USD"),
)),
];
let errors = validate(&directives);
let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
let early_pos = codes
.iter()
.position(|c| *c == ErrorCode::AccountNotOpen)
.unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
let late_pos = codes
.iter()
.position(|c| *c == ErrorCode::BalanceAssertionFailed)
.unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
let finalize_pos = codes
.iter()
.position(|c| *c == ErrorCode::PadWithoutBalance)
.unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
assert!(
early_pos < late_pos,
"early-phase errors must precede late-phase; got {codes:?}"
);
assert!(
late_pos < finalize_pos,
"late-phase errors must precede finalize; got {codes:?}"
);
}
#[test]
fn test_duplicate_same_day_close_emits_close_not_empty_once() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 10), "leave residue")
.with_posting(Posting::new("Assets:Bank", Amount::new(dec!(50), "USD")))
.with_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-50), "USD"),
)),
),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
];
let errors = validate(&directives);
let close_not_empty_count = errors
.iter()
.filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
.count();
assert_eq!(
close_not_empty_count, 1,
"AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
);
let account_closed_count = errors
.iter()
.filter(|e| e.code == ErrorCode::AccountClosed)
.count();
assert_eq!(
account_closed_count, 1,
"duplicate close should still report AccountClosed once; got {errors:?}"
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "called twice for Late")]
fn test_run_phase_duplicate_late_panics_in_debug() {
let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
let mut session = ValidationSession::new(ValidationOptions::default());
let _ = session.run_phase(&directives, Phase::Early, date(2030, 1, 1));
let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
}
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "Phase::Late) called before Phase::Early")]
fn test_run_phase_late_before_early_panics_in_debug() {
let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
let mut session = ValidationSession::new(ValidationOptions::default());
let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
}
}