1#![forbid(unsafe_code)]
44#![warn(missing_docs)]
45
46mod error;
47mod validators;
48
49pub use error::{ErrorCode, Severity, ValidationError, is_advisory_only_code};
50pub use validators::balance::balance_tolerance;
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Phase {
79 Early,
83 Late,
87}
88
89use validators::{
90 register_open_late, validate_balance_early, validate_balance_late, validate_close,
91 validate_close_late, validate_document, validate_note, validate_open, validate_pad,
92 validate_transaction_early, validate_transaction_late,
93};
94
95use rayon::prelude::*;
96use rustledger_core::NaiveDate;
97
98const PARALLEL_SORT_THRESHOLD: usize = 5000;
101
102const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
106use rust_decimal::Decimal;
107use rustc_hash::{FxHashMap, FxHashSet};
108use rustledger_core::{Account, BookingMethod, Commodity, Currency, Directive, Inventory};
109use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
110use std::collections::BTreeSet;
111
112#[derive(Debug, Clone)]
114struct AccountState {
115 opened: NaiveDate,
117 closed: Option<NaiveDate>,
119 currencies: FxHashSet<rustledger_core::Currency>,
121 booking: BookingMethod,
124}
125
126#[non_exhaustive]
128#[derive(Debug, Clone)]
129pub struct ValidationOptions {
130 pub require_commodities: bool,
132 pub check_documents: bool,
134 pub warn_future_dates: bool,
136 pub document_base: Option<std::path::PathBuf>,
138 pub document_dirs: Vec<std::path::PathBuf>,
142 pub document_source_dirs: Vec<std::path::PathBuf>,
151 pub account_types: Vec<String>,
154 pub infer_tolerance_from_cost: bool,
157 pub tolerance_multiplier: Decimal,
160 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
163 pub default_booking_method: BookingMethod,
173}
174
175impl Default for ValidationOptions {
176 fn default() -> Self {
177 Self {
178 require_commodities: false,
179 check_documents: true, warn_future_dates: false,
181 document_base: None,
182 document_dirs: Vec::new(),
183 document_source_dirs: Vec::new(),
184 account_types: vec![
185 "Assets".to_string(),
186 "Liabilities".to_string(),
187 "Equity".to_string(),
188 "Income".to_string(),
189 "Expenses".to_string(),
190 ],
191 infer_tolerance_from_cost: false,
193 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
195 default_booking_method: BookingMethod::default(),
196 }
197 }
198}
199
200impl ValidationOptions {
201 #[must_use]
203 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
204 self.account_types = types;
205 self
206 }
207
208 #[must_use]
210 pub const fn with_require_commodities(mut self, require: bool) -> Self {
211 self.require_commodities = require;
212 self
213 }
214
215 #[must_use]
217 pub const fn with_check_documents(mut self, check: bool) -> Self {
218 self.check_documents = check;
219 self
220 }
221
222 #[must_use]
224 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
225 self.warn_future_dates = warn;
226 self
227 }
228
229 #[must_use]
231 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
232 self.document_dirs = dirs;
233 self
234 }
235
236 #[must_use]
240 pub fn with_document_source_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
241 self.document_source_dirs = dirs;
242 self
243 }
244
245 #[must_use]
247 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
248 self.infer_tolerance_from_cost = infer;
249 self
250 }
251
252 #[must_use]
254 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
255 self.tolerance_multiplier = multiplier;
256 self
257 }
258
259 #[must_use]
261 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
262 self.inferred_tolerance_default = defaults;
263 self
264 }
265
266 #[must_use]
271 pub const fn with_default_booking_method(mut self, method: BookingMethod) -> Self {
272 self.default_booking_method = method;
273 self
274 }
275}
276
277#[derive(Debug, Clone)]
279struct PendingPad {
280 source_account: rustledger_core::Account,
282 date: NaiveDate,
284 padded_currencies: FxHashSet<rustledger_core::Currency>,
291 location: Option<(rustledger_parser::Span, u16)>,
295}
296
297#[derive(Debug, Clone)]
305pub struct BalanceActual {
306 pub date: NaiveDate,
308 pub account: Account,
310 pub currency: rustledger_core::Currency,
312 pub diff: rustledger_core::Decimal,
314}
315
316#[derive(Debug, Default)]
318pub struct LedgerState {
319 accounts: FxHashMap<rustledger_core::Account, AccountState>,
321 inventories: FxHashMap<rustledger_core::Account, Inventory>,
323 inventory_accounts: BTreeSet<Account>,
330 commodities: FxHashSet<rustledger_core::Currency>,
332 pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
334 options: ValidationOptions,
336 pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
347 pub(crate) account_not_open_early: FxHashSet<(u16, rustledger_core::Span)>,
359 pub(crate) lifecycle_deferred: FxHashSet<(u16, rustledger_core::Span, Account)>,
384 pub(crate) balance_actuals: Vec<BalanceActual>,
388}
389
390impl LedgerState {
391 #[must_use]
393 pub fn new() -> Self {
394 Self::default()
395 }
396
397 #[must_use]
399 pub fn with_options(options: ValidationOptions) -> Self {
400 Self {
401 options,
402 ..Default::default()
403 }
404 }
405
406 pub const fn set_require_commodities(&mut self, require: bool) {
408 self.options.require_commodities = require;
409 }
410
411 pub const fn set_check_documents(&mut self, check: bool) {
413 self.options.check_documents = check;
414 }
415
416 pub const fn set_warn_future_dates(&mut self, warn: bool) {
418 self.options.warn_future_dates = warn;
419 }
420
421 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
423 self.options.document_base = Some(base.into());
424 }
425
426 #[must_use]
428 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
429 self.inventories.get(account)
430 }
431
432 pub fn accounts(&self) -> impl Iterator<Item = &str> {
434 self.accounts.keys().map(rustledger_core::Account::as_str)
435 }
436
437 pub fn import_option_warnings(
445 &self,
446 warnings: &[(&str, &str)],
447 errors: &mut Vec<ValidationError>,
448 ) {
449 for &(code, message) in warnings {
450 let error_code = match code {
451 "E7001" => ErrorCode::UnknownOption,
452 "E7002" => ErrorCode::InvalidOptionValue,
453 "E7003" => ErrorCode::DuplicateOption,
454 _ => continue,
455 };
456 errors.push(ValidationError::new(
457 error_code,
458 message.to_string(),
459 NaiveDate::default(),
461 ));
462 }
463 }
464}
465
466trait ValidatableDirective: Sync {
475 fn directive(&self) -> &Directive;
476 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
480}
481
482impl ValidatableDirective for Directive {
483 fn directive(&self) -> &Directive {
484 self
485 }
486 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
487 None
488 }
489}
490
491impl ValidatableDirective for Spanned<Directive> {
492 fn directive(&self) -> &Directive {
493 &self.value
494 }
495 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
496 Some((self.span, self.file_id))
497 }
498}
499
500fn sum_account_subtree(
514 inventories: &FxHashMap<Account, Inventory>,
515 index: &BTreeSet<Account>,
516 account: &Account,
517 currency: &Currency,
518) -> Decimal {
519 let acct = account.as_str();
520 let mut total = inventories
522 .get(account)
523 .map_or(Decimal::ZERO, |inv| inv.units(currency));
524 let mut lower = String::with_capacity(acct.len() + 1);
529 lower.push_str(acct);
530 lower.push(':');
531 let mut upper = String::with_capacity(acct.len() + 1);
532 upper.push_str(acct);
533 upper.push(';');
534 let bounds = (
535 std::ops::Bound::Included(lower.as_str()),
536 std::ops::Bound::Excluded(upper.as_str()),
537 );
538 for sub in index.range::<str, _>(bounds) {
539 if let Some(inv) = inventories.get(sub) {
540 total += inv.units(currency);
541 }
542 }
543 total
544}
545
546fn validate_phase_inner<D: ValidatableDirective>(
557 directives: &[D],
558 state: &mut LedgerState,
559 phase: Phase,
560 today: NaiveDate,
561) -> Vec<ValidationError> {
562 let document_exists_cache = if phase == Phase::Early {
565 build_document_exists_cache(directives, &state.options)
566 } else {
567 FxHashMap::default()
568 };
569
570 let mut errors = Vec::new();
571
572 let mut keyed: Vec<(_, &D)> = directives
585 .iter()
586 .map(|d| (rustledger_core::booking_sort_key(d.directive()), d))
587 .collect();
588 if keyed.len() >= PARALLEL_SORT_THRESHOLD {
589 keyed.par_sort_by_key(|k| k.0);
590 } else {
591 keyed.sort_by_key(|k| k.0);
592 }
593
594 for (_, d) in keyed {
595 let directive = d.directive();
596 let date = directive.date();
597
598 let error_count_before = errors.len();
605
606 if phase == Phase::Early && state.options.warn_future_dates && date > today {
615 errors.push(ValidationError::new(
616 ErrorCode::FutureDate,
617 format!("Entry dated in the future: {date}"),
618 date,
619 ));
620 }
621
622 match (phase, directive) {
623 (Phase::Early, Directive::Open(open)) => {
625 validate_open(state, open, &mut errors);
626 }
627 (Phase::Late, Directive::Open(open)) => {
631 register_open_late(state, open);
632 }
633 (Phase::Early, Directive::Close(close)) => {
634 validate_close(state, close, &mut errors);
635 }
636 (Phase::Late, Directive::Close(close)) => {
637 validate_close_late(state, close, &mut errors);
638 }
639 (Phase::Early, Directive::Commodity(comm)) => {
640 state.commodities.insert(comm.currency.clone());
641 validate_commodity_precision_meta(comm, &mut errors);
642 }
643 (Phase::Early, Directive::Pad(pad)) => {
644 validate_pad(state, pad, d.span_info(), &mut errors);
645 }
646 (Phase::Early, Directive::Document(doc)) => {
647 let file_id = d.span_info().map(|(_, fid)| fid);
648 validate_document(state, doc, file_id, &document_exists_cache, &mut errors);
649 }
650 (Phase::Early, Directive::Note(note)) => {
651 validate_note(state, note, &mut errors);
652 }
653 (Phase::Early, Directive::Custom(custom)) => {
654 validate_budget_custom(custom, &mut errors);
655 }
656 (Phase::Early, Directive::Transaction(txn)) => {
658 validate_transaction_early(state, txn, &mut errors);
659 }
660 (Phase::Late, Directive::Transaction(txn)) => {
661 validate_transaction_late(state, txn, &mut errors);
662 }
663 (Phase::Early, Directive::Balance(bal)) => {
664 validate_balance_early(state, bal, &mut errors);
665 }
666 (Phase::Late, Directive::Balance(bal)) => {
667 validate_balance_late(state, bal, &mut errors);
668 }
669 _ => {}
671 }
672
673 if let Some((span, file_id)) = d.span_info() {
680 for error in errors.iter_mut().skip(error_count_before) {
681 if error.span.is_none() {
682 error.span = Some(span);
683 error.file_id = Some(file_id);
684 }
685 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
686 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
687 }
688 }
689 }
690 }
691
692 errors
693}
694
695const SYNTHESIZED_DIRECTIVE_NOTE: &str = "directive was synthesized by a plugin (no source location \
703 in your files); the responsible plugin is either an \
704 enabled auto-plugin (e.g. `auto_accounts`, or document \
705 discovery via `option \"documents\"`) or one of your \
706 `plugin \"…\"` declarations";
707
708fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
709 let mut errors = Vec::new();
710 for (target_account, pads) in &state.pending_pads {
711 for pad in pads {
712 if pad.padded_currencies.is_empty() {
713 let mut error = ValidationError::new(
714 ErrorCode::PadWithoutBalance,
715 "Unused Pad entry".to_string(),
716 pad.date,
717 )
718 .with_context(format!(
719 " {} pad {} {}",
720 pad.date, target_account, pad.source_account
721 ));
722 if let Some((span, file_id)) = pad.location {
728 error.span = Some(span);
729 error.file_id = Some(file_id);
730 if file_id == SYNTHESIZED_FILE_ID {
731 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
732 }
733 }
734 errors.push(error);
735 }
736 }
737 }
738 errors
739}
740
741fn build_document_exists_cache<'a, D: ValidatableDirective>(
763 directives: &'a [D],
764 options: &ValidationOptions,
765) -> FxHashMap<(&'a str, Option<u16>), bool> {
766 if !options.check_documents {
767 return FxHashMap::default();
768 }
769
770 let mut keys: FxHashSet<(&str, Option<u16>)> = FxHashSet::default();
776 for d in directives {
777 if let Directive::Document(doc) = d.directive() {
778 let file_id = d.span_info().map(|(_, fid)| fid);
779 keys.insert((doc.path.as_str(), file_id));
780 }
781 }
782 let keys: Vec<(&str, Option<u16>)> = keys.into_iter().collect();
783
784 let resolve = |(s, file_id): (&'a str, Option<u16>)| {
791 ((s, file_id), document_file_exists(s, file_id, options))
792 };
793
794 if keys.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
795 keys.into_par_iter().map(resolve).collect()
796 } else {
797 keys.into_iter().map(resolve).collect()
798 }
799}
800
801fn document_file_exists(path: &str, file_id: Option<u16>, options: &ValidationOptions) -> bool {
811 let doc_path = std::path::Path::new(path);
812 if doc_path.is_absolute() {
813 doc_path.exists()
814 } else if let Some(base) = &options.document_base {
815 base.join(doc_path).exists()
816 } else if !options.document_dirs.is_empty() {
817 options
818 .document_dirs
819 .iter()
820 .any(|dir| dir.join(doc_path).exists())
821 } else if let Some(dir) = file_id.and_then(|id| options.document_source_dirs.get(id as usize)) {
822 dir.join(doc_path).exists()
823 } else {
824 doc_path.exists()
825 }
826}
827
828pub mod phase {
873 mod sealed {
874 pub trait Sealed {}
875 }
876
877 pub trait SessionPhase: sealed::Sealed {}
880
881 macro_rules! define_phase {
882 ($name:ident, $doc:expr) => {
883 #[doc = $doc]
884 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
885 pub struct $name;
886 impl sealed::Sealed for $name {}
887 impl SessionPhase for $name {}
888 };
889 }
890
891 define_phase!(
892 Pending,
893 "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
894 );
895 define_phase!(
896 EarlyDone,
897 "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
898 );
899 define_phase!(
900 LateDone,
901 "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
902 );
903}
904
905pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
906
907pub struct ValidationSession<P: SessionPhase = Pending> {
980 state: LedgerState,
981 _phase: std::marker::PhantomData<P>,
982}
983
984impl<P: SessionPhase> ValidationSession<P> {
985 #[must_use]
991 pub fn balance_actuals(&self) -> &[BalanceActual] {
992 &self.state.balance_actuals
993 }
994}
995
996impl ValidationSession<Pending> {
997 #[must_use]
1002 pub fn new(options: ValidationOptions) -> Self {
1003 Self {
1004 state: LedgerState::with_options(options),
1005 _phase: std::marker::PhantomData,
1006 }
1007 }
1008
1009 #[must_use = "ValidationSession::run_early returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1020 pub fn run_early(
1021 self,
1022 directives: &[Directive],
1023 today: NaiveDate,
1024 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1025 self.run_phase_internal(directives, Phase::Early, today)
1026 }
1027
1028 #[must_use = "ValidationSession::run_early_spanned returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1032 pub fn run_early_spanned(
1033 self,
1034 directives: &[Spanned<Directive>],
1035 today: NaiveDate,
1036 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1037 self.run_phase_internal(directives, Phase::Early, today)
1038 }
1039
1040 fn run_phase_internal<D: ValidatableDirective>(
1048 mut self,
1049 directives: &[D],
1050 phase: Phase,
1051 today: NaiveDate,
1052 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1053 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1054 (
1055 ValidationSession {
1056 state: self.state,
1057 _phase: std::marker::PhantomData,
1058 },
1059 errors,
1060 )
1061 }
1062}
1063
1064impl ValidationSession<EarlyDone> {
1065 #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1075 pub fn run_late(
1076 self,
1077 directives: &[Directive],
1078 today: NaiveDate,
1079 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1080 self.run_phase_internal(directives, Phase::Late, today)
1081 }
1082
1083 #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1087 pub fn run_late_spanned(
1088 self,
1089 directives: &[Spanned<Directive>],
1090 today: NaiveDate,
1091 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1092 self.run_phase_internal(directives, Phase::Late, today)
1093 }
1094
1095 fn run_phase_internal<D: ValidatableDirective>(
1099 mut self,
1100 directives: &[D],
1101 phase: Phase,
1102 today: NaiveDate,
1103 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1104 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1105 (
1106 ValidationSession {
1107 state: self.state,
1108 _phase: std::marker::PhantomData,
1109 },
1110 errors,
1111 )
1112 }
1113}
1114
1115impl ValidationSession<LateDone> {
1116 #[must_use]
1120 pub fn finalize(self) -> Vec<ValidationError> {
1121 check_unused_pads(&self.state)
1122 }
1123}
1124
1125fn validate_budget_custom(custom: &rustledger_core::Custom, errors: &mut Vec<ValidationError>) {
1142 let confident = match rustledger_budget::read_budget(custom) {
1149 rustledger_budget::BudgetRead::Invalid(e) => Some(e),
1150 rustledger_budget::BudgetRead::Read { note, .. } => note,
1153 rustledger_budget::BudgetRead::NotABudget => None,
1154 };
1155 if let Some(e) = confident {
1156 errors.push(ValidationError::new(
1157 ErrorCode::MalformedBudget,
1158 e.reason,
1159 custom.date,
1160 ));
1161 }
1162}
1163
1164fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
1170 let Some(value) = comm.meta.get("precision") else {
1171 return;
1172 };
1173 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
1174 errors.push(ValidationError::new(
1175 ErrorCode::InvalidPrecisionMetadata,
1176 format!(
1177 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
1178 comm.currency
1179 ),
1180 comm.date,
1181 ));
1182 }
1183}
1184
1185#[cfg(test)]
1186mod tests {
1187 use super::*;
1188 use rust_decimal_macros::dec;
1189 use rustledger_core::{
1190 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
1191 };
1192
1193 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1194 rustledger_core::naive_date(year, month, day).unwrap()
1195 }
1196
1197 fn test_today() -> NaiveDate {
1201 date(2030, 1, 1)
1202 }
1203
1204 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
1209 validate_with_options(directives, ValidationOptions::default())
1210 }
1211
1212 fn validate_with_options(
1215 directives: &[Directive],
1216 options: ValidationOptions,
1217 ) -> Vec<ValidationError> {
1218 validate_with_today(directives, options, test_today())
1219 }
1220
1221 fn validate_with_today(
1225 directives: &[Directive],
1226 options: ValidationOptions,
1227 today: NaiveDate,
1228 ) -> Vec<ValidationError> {
1229 let session = ValidationSession::new(options);
1230 let (session, mut errors) = session.run_early(directives, today);
1231 let (session, late_errors) = session.run_late(directives, today);
1232 errors.extend(late_errors);
1233 errors.extend(session.finalize());
1234 errors
1235 }
1236
1237 #[test]
1238 fn sum_account_subtree_matches_scan_and_excludes_prefix_siblings() {
1239 let mut state = LedgerState::default();
1241 let fixture = [
1242 ("Assets:Bank", dec!(10)),
1243 ("Assets:Bank:Checking", dec!(40)),
1244 ("Assets:Bank:Savings", dec!(5)),
1245 ("Assets:BankAlias", dec!(99)), ("Assets:Other", dec!(7)),
1247 ];
1248 for (name, amt) in fixture {
1249 let acct = Account::from(name);
1250 let mut inv = Inventory::new();
1251 inv.add(rustledger_core::Position::simple(Amount::new(amt, "USD")))
1252 .expect("fixture fits in Decimal");
1253 state.inventories.insert(acct.clone(), inv);
1254 state.inventory_accounts.insert(acct);
1255 }
1256
1257 let cur = Currency::from("USD");
1258 for name in [
1260 "Assets:Bank",
1261 "Assets:Bank:Checking",
1262 "Assets:BankAlias",
1263 "Assets:Other",
1264 "Assets:Missing",
1265 ] {
1266 let acct = Account::from(name);
1267 let indexed =
1268 sum_account_subtree(&state.inventories, &state.inventory_accounts, &acct, &cur);
1269 let scan =
1270 rustledger_core::sum_account_and_subaccounts(state.inventories.iter(), name, &cur)
1271 .expect("fixture fits in Decimal");
1272 assert_eq!(indexed, scan, "indexed vs scan disagree for {name}");
1273 }
1274
1275 let bank = sum_account_subtree(
1277 &state.inventories,
1278 &state.inventory_accounts,
1279 &Account::from("Assets:Bank"),
1280 &cur,
1281 );
1282 assert_eq!(
1283 bank,
1284 dec!(55),
1285 "Assets:Bank must sum its subtree, excluding the Assets:BankAlias prefix sibling"
1286 );
1287 }
1288
1289 #[test]
1290 fn test_validate_account_lifecycle() {
1291 let directives = vec![
1292 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1293 Directive::Transaction(
1294 Transaction::new(date(2024, 1, 15), "Test")
1295 .with_synthesized_posting(Posting::new(
1296 "Assets:Bank",
1297 Amount::new(dec!(100), "USD"),
1298 ))
1299 .with_synthesized_posting(Posting::new(
1300 "Income:Salary",
1301 Amount::new(dec!(-100), "USD"),
1302 )),
1303 ),
1304 ];
1305
1306 let errors = validate(&directives);
1307
1308 assert!(errors
1310 .iter()
1311 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1312 }
1313
1314 #[test]
1315 fn test_validate_account_used_before_open() {
1316 let directives = vec![
1317 Directive::Transaction(
1318 Transaction::new(date(2024, 1, 1), "Test")
1319 .with_synthesized_posting(Posting::new(
1320 "Assets:Bank",
1321 Amount::new(dec!(100), "USD"),
1322 ))
1323 .with_synthesized_posting(Posting::new(
1324 "Income:Salary",
1325 Amount::new(dec!(-100), "USD"),
1326 )),
1327 ),
1328 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1329 ];
1330
1331 let errors = validate(&directives);
1332
1333 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1334 }
1335
1336 #[test]
1337 fn test_validate_account_used_after_close() {
1338 let directives = vec![
1339 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1340 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1341 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1342 Directive::Transaction(
1343 Transaction::new(date(2024, 7, 1), "Test")
1344 .with_synthesized_posting(Posting::new(
1345 "Assets:Bank",
1346 Amount::new(dec!(-50), "USD"),
1347 ))
1348 .with_synthesized_posting(Posting::new(
1349 "Expenses:Food",
1350 Amount::new(dec!(50), "USD"),
1351 )),
1352 ),
1353 ];
1354
1355 let errors = validate(&directives);
1356
1357 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1358 }
1359
1360 #[test]
1361 fn test_validate_balance_assertion() {
1362 let directives = vec![
1363 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1364 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1365 Directive::Transaction(
1366 Transaction::new(date(2024, 1, 15), "Deposit")
1367 .with_synthesized_posting(Posting::new(
1368 "Assets:Bank",
1369 Amount::new(dec!(1000.00), "USD"),
1370 ))
1371 .with_synthesized_posting(Posting::new(
1372 "Income:Salary",
1373 Amount::new(dec!(-1000.00), "USD"),
1374 )),
1375 ),
1376 Directive::Balance(Balance::new(
1377 date(2024, 1, 16),
1378 "Assets:Bank",
1379 Amount::new(dec!(1000.00), "USD"),
1380 )),
1381 ];
1382
1383 let errors = validate(&directives);
1384 assert!(errors.is_empty(), "{errors:?}");
1385 }
1386
1387 #[test]
1388 fn test_validate_balance_assertion_failed() {
1389 let directives = vec![
1390 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1391 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1392 Directive::Transaction(
1393 Transaction::new(date(2024, 1, 15), "Deposit")
1394 .with_synthesized_posting(Posting::new(
1395 "Assets:Bank",
1396 Amount::new(dec!(1000.00), "USD"),
1397 ))
1398 .with_synthesized_posting(Posting::new(
1399 "Income:Salary",
1400 Amount::new(dec!(-1000.00), "USD"),
1401 )),
1402 ),
1403 Directive::Balance(Balance::new(
1404 date(2024, 1, 16),
1405 "Assets:Bank",
1406 Amount::new(dec!(500.00), "USD"), )),
1408 ];
1409
1410 let errors = validate(&directives);
1411 assert!(
1412 errors
1413 .iter()
1414 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1415 );
1416 }
1417
1418 #[test]
1424 fn test_validate_balance_assertion_within_tolerance() {
1425 let directives = vec![
1430 Directive::Open(
1431 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1432 ),
1433 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1434 Directive::Transaction(
1435 Transaction::new(date(2024, 1, 15), "Deposit")
1436 .with_synthesized_posting(Posting::new(
1437 "Assets:Bank",
1438 Amount::new(dec!(70.538), "ABC"), ))
1440 .with_synthesized_posting(Posting::new(
1441 "Expenses:Misc",
1442 Amount::new(dec!(-70.538), "ABC"),
1443 )),
1444 ),
1445 Directive::Balance(Balance::new(
1446 date(2024, 1, 16),
1447 "Assets:Bank",
1448 Amount::new(dec!(70.53), "ABC"), )),
1450 ];
1451
1452 let errors = validate(&directives);
1453 assert!(
1454 errors.is_empty(),
1455 "Balance within tolerance should pass: {errors:?}"
1456 );
1457 }
1458
1459 #[test]
1461 fn test_validate_balance_assertion_exceeds_tolerance() {
1462 let directives = vec![
1467 Directive::Open(
1468 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1469 ),
1470 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1471 Directive::Transaction(
1472 Transaction::new(date(2024, 1, 15), "Deposit")
1473 .with_synthesized_posting(Posting::new(
1474 "Assets:Bank",
1475 Amount::new(dec!(70.542), "ABC"),
1476 ))
1477 .with_synthesized_posting(Posting::new(
1478 "Expenses:Misc",
1479 Amount::new(dec!(-70.542), "ABC"),
1480 )),
1481 ),
1482 Directive::Balance(Balance::new(
1483 date(2024, 1, 16),
1484 "Assets:Bank",
1485 Amount::new(dec!(70.53), "ABC"), )),
1487 ];
1488
1489 let errors = validate(&directives);
1490 assert!(
1491 errors
1492 .iter()
1493 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1494 "Balance exceeding tolerance should fail"
1495 );
1496 }
1497
1498 #[test]
1499 fn test_validate_unbalanced_transaction() {
1500 let directives = vec![
1501 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1502 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1503 Directive::Transaction(
1504 Transaction::new(date(2024, 1, 15), "Unbalanced")
1505 .with_synthesized_posting(Posting::new(
1506 "Assets:Bank",
1507 Amount::new(dec!(-50.00), "USD"),
1508 ))
1509 .with_synthesized_posting(Posting::new(
1510 "Expenses:Food",
1511 Amount::new(dec!(40.00), "USD"),
1512 )), ),
1514 ];
1515
1516 let errors = validate(&directives);
1517 assert!(
1518 errors
1519 .iter()
1520 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1521 );
1522 }
1523
1524 #[test]
1525 fn test_validate_currency_not_allowed() {
1526 let directives = vec![
1527 Directive::Open(
1528 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1529 ),
1530 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1531 Directive::Transaction(
1532 Transaction::new(date(2024, 1, 15), "Test")
1533 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_synthesized_posting(Posting::new(
1535 "Income:Salary",
1536 Amount::new(dec!(-100.00), "EUR"),
1537 )),
1538 ),
1539 ];
1540
1541 let errors = validate(&directives);
1542 assert!(
1543 errors
1544 .iter()
1545 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1546 );
1547 }
1548
1549 #[test]
1550 fn test_validate_balance_wrong_currency() {
1551 let directives = vec![
1554 Directive::Open(
1555 Open::new(date(2024, 1, 1), "Assets:Cash").with_currencies(vec!["USD".into()]),
1556 ),
1557 Directive::Balance(Balance::new(
1558 date(2024, 3, 1),
1559 "Assets:Cash",
1560 Amount::new(dec!(100), "EUR"),
1561 )),
1562 ];
1563 let errors = validate(&directives);
1564 assert!(
1565 errors
1566 .iter()
1567 .any(|e| e.code == ErrorCode::CurrencyNotAllowed
1568 && e.message.contains("for Balance directive")),
1569 "balance in a non-allowed currency should be flagged (#1668); got: {:?}",
1570 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1571 );
1572 }
1573
1574 #[test]
1575 fn test_validate_balance_currency_allowed_when_unconstrained() {
1576 let directives = vec![
1579 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1580 Directive::Balance(Balance::new(
1581 date(2024, 3, 1),
1582 "Assets:Cash",
1583 Amount::new(dec!(0), "EUR"),
1584 )),
1585 ];
1586 let errors = validate(&directives);
1587 assert!(
1588 !errors
1589 .iter()
1590 .any(|e| e.code == ErrorCode::CurrencyNotAllowed),
1591 "unconstrained account must not flag balance currency (#1668); got: {:?}",
1592 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1593 );
1594 }
1595
1596 #[test]
1597 fn test_validate_future_date_warning() {
1598 let today = date(2024, 1, 1);
1602 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1603
1604 let directives = vec![Directive::Open(Open {
1605 date: future_date,
1606 account: "Assets:Bank".into(),
1607 currencies: vec![],
1608 booking: None,
1609 meta: Default::default(),
1610 })];
1611
1612 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1614 assert!(
1615 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1616 "Should not warn about future dates by default"
1617 );
1618
1619 let options = ValidationOptions::default().with_warn_future_dates(true);
1621 let errors = validate_with_today(&directives, options, today);
1622 assert!(
1623 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1624 "Should warn about future dates when enabled"
1625 );
1626 }
1627
1628 #[test]
1635 fn test_validate_with_today_threads_today_parameter() {
1636 let directives = vec![Directive::Open(Open {
1637 date: date(2024, 6, 15),
1638 account: "Assets:Bank".into(),
1639 currencies: vec![],
1640 booking: None,
1641 meta: Default::default(),
1642 })];
1643 let options = ValidationOptions::default().with_warn_future_dates(true);
1644
1645 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1647 assert!(
1648 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1649 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1650 );
1651
1652 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1654 assert!(
1655 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1656 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1657 );
1658 }
1659
1660 #[test]
1661 fn test_validate_document_not_found() {
1662 let directives = vec![
1663 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1664 Directive::Document(Document {
1665 date: date(2024, 1, 15),
1666 account: "Assets:Bank".into(),
1667 path: "/nonexistent/path/to/document.pdf".to_string(),
1668 tags: vec![],
1669 links: vec![],
1670 meta: Default::default(),
1671 }),
1672 ];
1673
1674 let errors = validate(&directives);
1676 assert!(
1677 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1678 "Should check documents by default"
1679 );
1680
1681 let options = ValidationOptions::default().with_check_documents(false);
1683 let errors = validate_with_options(&directives, options);
1684 assert!(
1685 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1686 "Should not report missing document when disabled"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_validate_document_account_not_open() {
1692 let directives = vec![Directive::Document(Document {
1693 date: date(2024, 1, 15),
1694 account: "Assets:Unknown".into(),
1695 path: "receipt.pdf".to_string(),
1696 tags: vec![],
1697 links: vec![],
1698 meta: Default::default(),
1699 })];
1700
1701 let errors = validate(&directives);
1702 assert!(
1703 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1704 "Should error for document on unopened account"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_validate_document_relative_path_in_document_dirs() {
1710 let filename = "rustledger_test_889_relative_receipt.pdf";
1714 let dir = tempfile::tempdir().unwrap();
1715 let doc_subdir = dir.path().join("documents");
1716 std::fs::create_dir_all(&doc_subdir).unwrap();
1717 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1718
1719 let directives = vec![
1720 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1721 Directive::Document(Document {
1722 date: date(2024, 1, 15),
1723 account: "Assets:Bank".into(),
1724 path: filename.to_string(),
1725 tags: vec![],
1726 links: vec![],
1727 meta: Default::default(),
1728 }),
1729 ];
1730
1731 let errors = validate(&directives);
1733 assert!(
1734 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1735 "Should error when document_dirs not set"
1736 );
1737
1738 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1740 let errors = validate_with_options(&directives, options);
1741 assert!(
1742 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1743 "Should find document in document_dirs: {errors:?}"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_validate_document_relative_path_not_found_in_dirs() {
1749 let filename = "rustledger_test_889_nonexistent.pdf";
1751 let dir = tempfile::tempdir().unwrap();
1752 let doc_subdir = dir.path().join("documents");
1753 std::fs::create_dir_all(&doc_subdir).unwrap();
1754
1755 let directives = vec![
1756 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1757 Directive::Document(Document {
1758 date: date(2024, 1, 15),
1759 account: "Assets:Bank".into(),
1760 path: filename.to_string(),
1761 tags: vec![],
1762 links: vec![],
1763 meta: Default::default(),
1764 }),
1765 ];
1766
1767 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1768 let errors = validate_with_options(&directives, options);
1769 assert!(
1770 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1771 "Should error when file not found in any document_dir"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_validate_document_absolute_path_ignores_document_dirs() {
1777 let filename = "rustledger_test_889_absolute_receipt.pdf";
1778 let dir = tempfile::tempdir().unwrap();
1779 let doc_subdir = dir.path().join("documents");
1780 std::fs::create_dir_all(&doc_subdir).unwrap();
1781 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1782
1783 let directives = vec![
1784 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1785 Directive::Document(Document {
1786 date: date(2024, 1, 15),
1787 account: "Assets:Bank".into(),
1788 path: doc_subdir.join(filename).display().to_string(),
1789 tags: vec![],
1790 links: vec![],
1791 meta: Default::default(),
1792 }),
1793 ];
1794
1795 let options = ValidationOptions::default()
1797 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1798 let errors = validate_with_options(&directives, options);
1799 assert!(
1800 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1801 "Absolute path should work even with wrong document_dirs: {errors:?}"
1802 );
1803 }
1804
1805 #[test]
1814 fn test_validate_document_parallel_batch_check() {
1815 let dir = tempfile::tempdir().unwrap();
1816 let doc_subdir = dir.path().join("docs");
1817 std::fs::create_dir_all(&doc_subdir).unwrap();
1818
1819 let mut directives: Vec<Directive> =
1822 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1823 for i in 0..100 {
1824 let filename = format!("receipt_{i}.pdf");
1825 if i % 2 == 0 {
1826 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1827 }
1828 directives.push(Directive::Document(Document {
1829 date: date(2024, 1, 15),
1830 account: "Assets:Bank".into(),
1831 path: filename,
1832 tags: vec![],
1833 links: vec![],
1834 meta: Default::default(),
1835 }));
1836 }
1837
1838 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1839 let errors = validate_with_options(&directives, options);
1840
1841 let not_found_count = errors
1842 .iter()
1843 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1844 .count();
1845 assert_eq!(
1846 not_found_count, 50,
1847 "exactly 50 of 100 documents should error as not-found"
1848 );
1849
1850 let example = errors
1854 .iter()
1855 .find(|e| e.code == ErrorCode::DocumentNotFound)
1856 .expect("should have at least one not-found error");
1857 assert!(
1858 example
1859 .context
1860 .as_deref()
1861 .is_some_and(|c| c.contains("searched")),
1862 "error context should mention the searched dirs, got: {:?}",
1863 example.context
1864 );
1865 }
1866
1867 #[test]
1868 fn test_error_code_is_warning() {
1869 assert!(!ErrorCode::AccountNotOpen.is_warning());
1870 assert!(!ErrorCode::DocumentNotFound.is_warning());
1871 assert!(ErrorCode::FutureDate.is_warning());
1872 }
1873
1874 #[test]
1875 fn test_validate_pad_basic() {
1876 let directives = vec![
1877 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1878 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1879 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1880 Directive::Balance(Balance::new(
1881 date(2024, 1, 2),
1882 "Assets:Bank",
1883 Amount::new(dec!(1000.00), "USD"),
1884 )),
1885 ];
1886
1887 let errors = validate(&directives);
1888 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1890 }
1891
1892 #[test]
1893 fn test_validate_pad_with_existing_balance() {
1894 let directives = vec![
1895 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1896 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1897 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1898 Directive::Transaction(
1900 Transaction::new(date(2024, 1, 5), "Initial deposit")
1901 .with_synthesized_posting(Posting::new(
1902 "Assets:Bank",
1903 Amount::new(dec!(500.00), "USD"),
1904 ))
1905 .with_synthesized_posting(Posting::new(
1906 "Income:Salary",
1907 Amount::new(dec!(-500.00), "USD"),
1908 )),
1909 ),
1910 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1912 Directive::Balance(Balance::new(
1913 date(2024, 1, 15),
1914 "Assets:Bank",
1915 Amount::new(dec!(1000.00), "USD"), )),
1917 ];
1918
1919 let errors = validate(&directives);
1920 assert!(
1922 errors.is_empty(),
1923 "Pad should add missing amount: {errors:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn test_validate_pad_account_not_open() {
1929 let directives = vec![
1930 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1931 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1933 ];
1934
1935 let errors = validate(&directives);
1936 assert!(
1937 errors
1938 .iter()
1939 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1940 "Should error for pad on unopened account"
1941 );
1942 }
1943
1944 #[test]
1945 fn test_validate_pad_source_not_open() {
1946 let directives = vec![
1947 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1948 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1950 ];
1951
1952 let errors = validate(&directives);
1953 assert!(
1954 errors.iter().any(
1955 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1956 ),
1957 "Should error for pad with unopened source account"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_validate_pad_negative_adjustment() {
1963 let directives = vec![
1965 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1966 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1967 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1968 Directive::Transaction(
1970 Transaction::new(date(2024, 1, 5), "Big deposit")
1971 .with_synthesized_posting(Posting::new(
1972 "Assets:Bank",
1973 Amount::new(dec!(2000.00), "USD"),
1974 ))
1975 .with_synthesized_posting(Posting::new(
1976 "Income:Salary",
1977 Amount::new(dec!(-2000.00), "USD"),
1978 )),
1979 ),
1980 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1982 Directive::Balance(Balance::new(
1983 date(2024, 1, 15),
1984 "Assets:Bank",
1985 Amount::new(dec!(1000.00), "USD"), )),
1987 ];
1988
1989 let errors = validate(&directives);
1990 assert!(
1991 errors.is_empty(),
1992 "Pad should handle negative adjustment: {errors:?}"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_validate_insufficient_units() {
1998 use rustledger_core::CostSpec;
1999
2000 let cost_spec = CostSpec::empty()
2001 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2002 .with_currency("USD");
2003
2004 let directives = vec![
2005 Directive::Open(
2006 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2007 ),
2008 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2009 Directive::Transaction(
2011 Transaction::new(date(2024, 1, 15), "Buy")
2012 .with_synthesized_posting(
2013 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2014 .with_cost(cost_spec.clone()),
2015 )
2016 .with_synthesized_posting(Posting::new(
2017 "Assets:Cash",
2018 Amount::new(dec!(-1500), "USD"),
2019 )),
2020 ),
2021 Directive::Transaction(
2023 Transaction::new(date(2024, 6, 1), "Sell too many")
2024 .with_synthesized_posting(
2025 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
2026 .with_cost(cost_spec),
2027 )
2028 .with_synthesized_posting(Posting::new(
2029 "Assets:Cash",
2030 Amount::new(dec!(2250), "USD"),
2031 )),
2032 ),
2033 ];
2034
2035 let errors = validate(&directives);
2036 assert!(
2037 errors
2038 .iter()
2039 .any(|e| e.code == ErrorCode::InsufficientUnits),
2040 "Should error for insufficient units: {errors:?}"
2041 );
2042 }
2043
2044 #[test]
2045 fn test_validate_no_matching_lot() {
2046 use rustledger_core::CostSpec;
2047
2048 let directives = vec![
2049 Directive::Open(
2050 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2051 ),
2052 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2053 Directive::Transaction(
2055 Transaction::new(date(2024, 1, 15), "Buy")
2056 .with_synthesized_posting(
2057 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
2058 CostSpec::empty()
2059 .with_number(rustledger_core::CostNumber::PerUnit {
2060 value: dec!(150),
2061 })
2062 .with_currency("USD"),
2063 ),
2064 )
2065 .with_synthesized_posting(Posting::new(
2066 "Assets:Cash",
2067 Amount::new(dec!(-1500), "USD"),
2068 )),
2069 ),
2070 Directive::Transaction(
2072 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
2073 .with_synthesized_posting(
2074 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
2075 CostSpec::empty()
2076 .with_number(rustledger_core::CostNumber::PerUnit {
2077 value: dec!(160),
2078 })
2079 .with_currency("USD"),
2080 ),
2081 )
2082 .with_synthesized_posting(Posting::new(
2083 "Assets:Cash",
2084 Amount::new(dec!(800), "USD"),
2085 )),
2086 ),
2087 ];
2088
2089 let errors = validate(&directives);
2090 assert!(
2091 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
2092 "Should error for no matching lot: {errors:?}"
2093 );
2094 }
2095
2096 #[test]
2097 fn test_validate_reports_a_partial_sale_matching_two_dated_lots() {
2098 use rustledger_core::CostSpec;
2109
2110 let cost_spec = CostSpec::empty()
2111 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2112 .with_currency("USD");
2113
2114 let directives = vec![
2115 Directive::Open(
2116 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2117 ),
2118 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2119 Directive::Transaction(
2121 Transaction::new(date(2024, 1, 15), "Buy lot 1")
2122 .with_synthesized_posting(
2123 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2124 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
2125 )
2126 .with_synthesized_posting(Posting::new(
2127 "Assets:Cash",
2128 Amount::new(dec!(-1500), "USD"),
2129 )),
2130 ),
2131 Directive::Transaction(
2133 Transaction::new(date(2024, 2, 15), "Buy lot 2")
2134 .with_synthesized_posting(
2135 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2136 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
2137 )
2138 .with_synthesized_posting(Posting::new(
2139 "Assets:Cash",
2140 Amount::new(dec!(-1500), "USD"),
2141 )),
2142 ),
2143 Directive::Transaction(
2145 Transaction::new(date(2024, 6, 1), "Sell matching two lots")
2146 .with_synthesized_posting(
2147 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2148 .with_cost(cost_spec),
2149 )
2150 .with_synthesized_posting(Posting::new(
2151 "Assets:Cash",
2152 Amount::new(dec!(750), "USD"),
2153 )),
2154 ),
2155 ];
2156
2157 let errors = validate(&directives);
2158 let ambiguous = errors
2159 .iter()
2160 .filter(|e| e.code == ErrorCode::AmbiguousLotMatch)
2161 .count();
2162 assert_eq!(
2163 ambiguous, 1,
2164 "a sale naming only the price matches both dated lots and must be \
2165 reported, not resolved silently: {errors:?}"
2166 );
2167 }
2168
2169 #[test]
2170 fn test_validate_successful_booking() {
2171 use rustledger_core::CostSpec;
2172
2173 let cost_spec = CostSpec::empty()
2174 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2175 .with_currency("USD");
2176
2177 let directives = vec![
2178 Directive::Open(
2179 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2180 ),
2181 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2182 Directive::Transaction(
2184 Transaction::new(date(2024, 1, 15), "Buy")
2185 .with_synthesized_posting(
2186 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2187 .with_cost(cost_spec.clone()),
2188 )
2189 .with_synthesized_posting(Posting::new(
2190 "Assets:Cash",
2191 Amount::new(dec!(-1500), "USD"),
2192 )),
2193 ),
2194 Directive::Transaction(
2196 Transaction::new(date(2024, 6, 1), "Sell")
2197 .with_synthesized_posting(
2198 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2199 .with_cost(cost_spec),
2200 )
2201 .with_synthesized_posting(Posting::new(
2202 "Assets:Cash",
2203 Amount::new(dec!(750), "USD"),
2204 )),
2205 ),
2206 ];
2207
2208 let errors = validate(&directives);
2209 let booking_errors: Vec<_> = errors
2211 .iter()
2212 .filter(|e| {
2213 matches!(
2214 e.code,
2215 ErrorCode::InsufficientUnits
2216 | ErrorCode::NoMatchingLot
2217 | ErrorCode::AmbiguousLotMatch
2218 )
2219 })
2220 .collect();
2221 assert!(
2222 booking_errors.is_empty(),
2223 "Should have no booking errors: {booking_errors:?}"
2224 );
2225 }
2226
2227 #[test]
2228 fn test_validate_account_already_open() {
2229 let directives = vec![
2230 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2231 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2233
2234 let errors = validate(&directives);
2235 assert!(
2236 errors
2237 .iter()
2238 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2239 "Should error for duplicate open: {errors:?}"
2240 );
2241 }
2242
2243 #[test]
2244 fn test_validate_account_close_not_empty() {
2245 let directives = vec![
2246 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2247 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2248 Directive::Transaction(
2249 Transaction::new(date(2024, 1, 15), "Deposit")
2250 .with_synthesized_posting(Posting::new(
2251 "Assets:Bank",
2252 Amount::new(dec!(100.00), "USD"),
2253 ))
2254 .with_synthesized_posting(Posting::new(
2255 "Income:Salary",
2256 Amount::new(dec!(-100.00), "USD"),
2257 )),
2258 ),
2259 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2261
2262 let errors = validate(&directives);
2263 assert!(
2264 errors
2265 .iter()
2266 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2267 "Should warn for closing account with balance: {errors:?}"
2268 );
2269 }
2270
2271 #[test]
2272 fn test_validate_no_postings_allowed() {
2273 let directives = vec![
2276 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2277 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2278 ];
2279
2280 let errors = validate(&directives);
2281 assert!(
2282 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2283 "Should NOT error for transaction with no postings: {errors:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_validate_single_posting() {
2289 let directives = vec![
2290 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2291 Directive::Transaction(
2292 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2293 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2294 ),
2295 ),
2296 ];
2297
2298 let errors = validate(&directives);
2299 assert!(
2300 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2301 "Should warn for transaction with single posting: {errors:?}"
2302 );
2303 assert!(ErrorCode::SinglePosting.is_warning());
2305 }
2306
2307 #[test]
2308 fn test_validate_single_posting_zero_cost_no_warning() {
2309 let directives = vec![
2313 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2314 Directive::Transaction(
2315 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2316 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2317 rustledger_core::CostSpec::empty()
2318 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2319 .with_currency("USD"),
2320 ),
2321 ),
2322 ),
2323 ];
2324
2325 let errors = validate(&directives);
2326 assert!(
2327 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2328 "Should NOT warn for zero-cost single posting: {errors:?}"
2329 );
2330 }
2331
2332 #[test]
2333 fn test_validate_single_posting_nonzero_cost_still_warns() {
2334 let directives = vec![
2336 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2337 Directive::Transaction(
2338 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2339 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2340 rustledger_core::CostSpec::empty()
2341 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2342 .with_currency("USD"),
2343 ),
2344 ),
2345 ),
2346 ];
2347
2348 let errors = validate(&directives);
2349 assert!(
2350 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2351 "Should warn for single posting with non-zero cost: {errors:?}"
2352 );
2353 }
2354
2355 #[test]
2356 fn test_validate_pad_without_balance() {
2357 let directives = vec![
2358 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2359 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2360 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2361 ];
2363
2364 let errors = validate(&directives);
2365 assert!(
2366 errors
2367 .iter()
2368 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2369 "Should error for pad without subsequent balance: {errors:?}"
2370 );
2371 }
2372
2373 #[test]
2374 fn test_validate_multiple_pads_for_balance() {
2375 let directives = vec![
2376 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2377 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2378 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2379 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2381 date(2024, 1, 3),
2382 "Assets:Bank",
2383 Amount::new(dec!(1000.00), "USD"),
2384 )),
2385 ];
2386
2387 let errors = validate(&directives);
2388 assert!(
2389 errors
2390 .iter()
2391 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2392 "Should error for multiple pads before balance: {errors:?}"
2393 );
2394 }
2395
2396 #[test]
2397 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2398 let directives = vec![
2404 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2405 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2406 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2408 Directive::Balance(Balance::new(
2409 date(2024, 1, 2),
2410 "Assets:Bank",
2411 Amount::new(dec!(100.00), "USD"),
2412 )),
2413 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2416 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2417 Directive::Balance(Balance::new(
2418 date(2024, 2, 3),
2419 "Assets:Bank",
2420 Amount::new(dec!(200.00), "USD"),
2421 )),
2422 ];
2423
2424 let errors = validate(&directives);
2425 let multi_pad_count = errors
2426 .iter()
2427 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2428 .count();
2429 assert_eq!(
2430 multi_pad_count, 1,
2431 "E2004 must fire exactly once on the second balance; got {errors:?}"
2432 );
2433 }
2434
2435 #[test]
2436 fn test_pad_serves_multi_currency_balances_on_same_day() {
2437 let directives = vec![
2444 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2445 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2446 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2447 Directive::Balance(Balance::new(
2449 date(2024, 1, 2),
2450 "Assets:Bank",
2451 Amount::new(dec!(100.00), "USD"),
2452 )),
2453 Directive::Balance(Balance::new(
2454 date(2024, 1, 2),
2455 "Assets:Bank",
2456 Amount::new(dec!(50.00), "EUR"),
2457 )),
2458 ];
2459
2460 let errors = validate(&directives);
2461 assert!(
2462 !errors
2463 .iter()
2464 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2465 "pad should serve both USD and EUR; got {errors:?}"
2466 );
2467 assert!(
2468 !errors
2469 .iter()
2470 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2471 "pad serves at least one balance; should not be E2003; got {errors:?}"
2472 );
2473 }
2474
2475 #[test]
2476 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2477 let directives = vec![
2482 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2483 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2484 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2485 Directive::Balance(Balance::new(
2486 date(2024, 1, 2),
2487 "Assets:Bank",
2488 Amount::new(dec!(100.00), "USD"),
2489 )),
2490 ];
2491
2492 let errors = validate(&directives);
2493 assert!(
2497 errors
2498 .iter()
2499 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2500 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2501 );
2502 assert!(
2504 errors
2505 .iter()
2506 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2507 "same-day pad never consumed; expected E2003; got {errors:?}"
2508 );
2509 }
2510
2511 #[test]
2512 fn test_future_pad_does_not_apply_to_earlier_balance() {
2513 let directives = vec![
2519 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2520 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2521 Directive::Balance(Balance::new(
2522 date(2024, 1, 2),
2523 "Assets:Bank",
2524 Amount::new(dec!(0.00), "USD"),
2525 )),
2526 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2527 ];
2528
2529 let errors = validate(&directives);
2530 assert!(
2533 !errors
2534 .iter()
2535 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2536 "future pad should not influence earlier balance; got {errors:?}"
2537 );
2538 assert!(
2540 errors
2541 .iter()
2542 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2543 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_error_severity() {
2549 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2551 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2552 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2553
2554 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2556 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2557 assert_eq!(
2558 ErrorCode::AccountCloseNotEmpty.severity(),
2559 Severity::Warning
2560 );
2561
2562 }
2564
2565 #[test]
2566 fn test_validate_invalid_account_name() {
2567 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2569
2570 let errors = validate(&directives);
2571 assert!(
2572 errors
2573 .iter()
2574 .any(|e| e.code == ErrorCode::InvalidAccountName),
2575 "Should error for invalid account root: {errors:?}"
2576 );
2577 }
2578
2579 #[test]
2580 fn test_validate_account_lowercase_component() {
2581 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2583
2584 let errors = validate(&directives);
2585 assert!(
2586 errors
2587 .iter()
2588 .any(|e| e.code == ErrorCode::InvalidAccountName),
2589 "Should error for lowercase component: {errors:?}"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_validate_valid_account_names() {
2595 let valid_names = [
2597 "Assets:Bank",
2598 "Assets:Bank:Checking",
2599 "Liabilities:CreditCard",
2600 "Equity:Opening-Balances",
2601 "Income:Salary2024",
2602 "Expenses:Food:Restaurant",
2603 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2609
2610 for name in valid_names {
2611 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2612
2613 let errors = validate(&directives);
2614 let name_errors: Vec<_> = errors
2615 .iter()
2616 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2617 .collect();
2618 assert!(
2619 name_errors.is_empty(),
2620 "Should accept valid account name '{name}': {name_errors:?}"
2621 );
2622 }
2623 }
2624
2625 #[test]
2630 fn test_e2002_balance_exceeds_explicit_tolerance() {
2631 let directives = vec![
2634 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2635 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2636 Directive::Transaction(
2637 Transaction::new(date(2024, 1, 15), "Deposit")
2638 .with_synthesized_posting(Posting::new(
2639 "Assets:Bank",
2640 Amount::new(dec!(1000.00), "USD"),
2641 ))
2642 .with_synthesized_posting(Posting::new(
2643 "Income:Salary",
2644 Amount::new(dec!(-1000.00), "USD"),
2645 )),
2646 ),
2647 Directive::Balance(
2650 Balance::new(
2651 date(2024, 1, 16),
2652 "Assets:Bank",
2653 Amount::new(dec!(999.00), "USD"),
2654 )
2655 .with_tolerance(dec!(0.01)),
2656 ),
2657 ];
2658
2659 let errors = validate(&directives);
2660
2661 assert!(
2662 errors
2663 .iter()
2664 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2665 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_e2002_balance_within_explicit_tolerance_passes() {
2671 let directives = vec![
2673 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2674 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2675 Directive::Transaction(
2676 Transaction::new(date(2024, 1, 15), "Deposit")
2677 .with_synthesized_posting(Posting::new(
2678 "Assets:Bank",
2679 Amount::new(dec!(1000.00), "USD"),
2680 ))
2681 .with_synthesized_posting(Posting::new(
2682 "Income:Salary",
2683 Amount::new(dec!(-1000.00), "USD"),
2684 )),
2685 ),
2686 Directive::Balance(
2688 Balance::new(
2689 date(2024, 1, 16),
2690 "Assets:Bank",
2691 Amount::new(dec!(999.00), "USD"),
2692 )
2693 .with_tolerance(dec!(5.00)),
2694 ),
2695 ];
2696
2697 let errors = validate(&directives);
2698
2699 assert!(
2700 !errors
2701 .iter()
2702 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2703 || e.code == ErrorCode::BalanceAssertionFailed),
2704 "Expected no balance errors, got: {errors:?}"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_e5001_undeclared_currency() {
2710 use rustledger_core::Commodity;
2713
2714 let directives = vec![
2715 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2716 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2717 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2718 Directive::Transaction(
2719 Transaction::new(date(2024, 1, 15), "Lunch")
2720 .with_synthesized_posting(Posting::new(
2721 "Expenses:Food",
2722 Amount::new(dec!(20.00), "EUR"), ))
2724 .with_synthesized_posting(Posting::new(
2725 "Assets:Bank",
2726 Amount::new(dec!(-20.00), "EUR"),
2727 )),
2728 ),
2729 ];
2730
2731 let options = ValidationOptions::default().with_require_commodities(true);
2732 let errors = validate_with_options(&directives, options);
2733
2734 assert!(
2735 errors
2736 .iter()
2737 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2738 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2739 );
2740 }
2741
2742 #[test]
2743 fn test_e5001_declared_currency_passes() {
2744 use rustledger_core::Commodity;
2746
2747 let directives = vec![
2748 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2749 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2750 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2751 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2752 Directive::Transaction(
2753 Transaction::new(date(2024, 1, 15), "Lunch")
2754 .with_synthesized_posting(Posting::new(
2755 "Expenses:Food",
2756 Amount::new(dec!(20.00), "EUR"),
2757 ))
2758 .with_synthesized_posting(Posting::new(
2759 "Assets:Bank",
2760 Amount::new(dec!(-20.00), "EUR"),
2761 )),
2762 ),
2763 ];
2764
2765 let options = ValidationOptions::default().with_require_commodities(true);
2766 let errors = validate_with_options(&directives, options);
2767
2768 assert!(
2769 !errors
2770 .iter()
2771 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2772 "Expected no E5001 errors, got: {errors:?}"
2773 );
2774 }
2775
2776 #[test]
2777 fn test_e5001_not_raised_without_require_commodities() {
2778 let directives = vec![
2780 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2781 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2782 Directive::Transaction(
2783 Transaction::new(date(2024, 1, 15), "Lunch")
2784 .with_synthesized_posting(Posting::new(
2785 "Expenses:Food",
2786 Amount::new(dec!(20.00), "XYZ"), ))
2788 .with_synthesized_posting(Posting::new(
2789 "Assets:Bank",
2790 Amount::new(dec!(-20.00), "XYZ"),
2791 )),
2792 ),
2793 ];
2794
2795 let errors = validate(&directives);
2796
2797 assert!(
2798 !errors
2799 .iter()
2800 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2801 "Should not raise E5001 without require_commodities, got: {errors:?}"
2802 );
2803 }
2804
2805 #[test]
2806 fn test_e3002_multiple_missing_amounts() {
2807 let directives = vec![
2809 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2810 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2811 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2812 Directive::Transaction(
2813 Transaction::new(date(2024, 1, 15), "Lunch")
2814 .with_synthesized_posting(Posting::new(
2815 "Assets:Bank",
2816 Amount::new(dec!(-50.00), "USD"),
2817 ))
2818 .with_synthesized_posting(Posting {
2820 account: "Expenses:Food".into(),
2821 units: None,
2822 cost: None,
2823 price: None,
2824 flag: None,
2825 meta: Default::default(),
2826 comments: vec![],
2827 trailing_comments: vec![],
2828 })
2829 .with_synthesized_posting(Posting {
2830 account: "Expenses:Drinks".into(),
2831 units: None,
2832 cost: None,
2833 price: None,
2834 flag: None,
2835 meta: Default::default(),
2836 comments: vec![],
2837 trailing_comments: vec![],
2838 }),
2839 ),
2840 ];
2841
2842 let errors = validate(&directives);
2843
2844 assert!(
2845 errors
2846 .iter()
2847 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2848 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2849 );
2850 }
2851
2852 #[test]
2853 fn test_e3002_single_missing_amount_ok() {
2854 let directives = vec![
2856 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2857 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2858 Directive::Transaction(
2859 Transaction::new(date(2024, 1, 15), "Lunch")
2860 .with_synthesized_posting(Posting::new(
2861 "Assets:Bank",
2862 Amount::new(dec!(-50.00), "USD"),
2863 ))
2864 .with_synthesized_posting(Posting {
2865 account: "Expenses:Food".into(),
2866 units: None,
2867 cost: None,
2868 price: None,
2869 flag: None,
2870 meta: Default::default(),
2871 comments: vec![],
2872 trailing_comments: vec![],
2873 }),
2874 ),
2875 ];
2876
2877 let errors = validate(&directives);
2878
2879 assert!(
2880 !errors
2881 .iter()
2882 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2883 "Should not raise E3002 with single missing amount, got: {errors:?}"
2884 );
2885 }
2886
2887 #[test]
2892 fn test_e3002_two_missing_in_different_currencies_ok() {
2893 let elided = |account: &str, currency: &str| Posting {
2894 account: account.into(),
2895 units: Some(rustledger_core::IncompleteAmount::CurrencyOnly(
2896 currency.into(),
2897 )),
2898 cost: None,
2899 price: None,
2900 flag: None,
2901 meta: Default::default(),
2902 comments: vec![],
2903 trailing_comments: vec![],
2904 };
2905
2906 let directives = vec![
2907 Directive::Open(Open::new(date(2024, 1, 1), "Assets:A")),
2908 Directive::Open(Open::new(date(2024, 1, 1), "Assets:B")),
2909 Directive::Open(Open::new(date(2024, 1, 1), "Assets:C")),
2910 Directive::Open(Open::new(date(2024, 1, 1), "Assets:D")),
2911 Directive::Transaction(
2912 Transaction::new(date(2024, 1, 15), "Two currencies")
2913 .with_synthesized_posting(elided("Assets:A", "USD"))
2914 .with_synthesized_posting(elided("Assets:B", "EUR"))
2915 .with_synthesized_posting(Posting::new(
2916 "Assets:C",
2917 Amount::new(dec!(-600.00), "USD"),
2918 ))
2919 .with_synthesized_posting(Posting::new(
2920 "Assets:D",
2921 Amount::new(dec!(-50.00), "EUR"),
2922 )),
2923 ),
2924 ];
2925
2926 let errors = validate(&directives);
2927
2928 assert!(
2929 !errors
2930 .iter()
2931 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2932 "USD and EUR unknowns do not compete; got: {errors:?}"
2933 );
2934 }
2935
2936 #[test]
2939 fn test_e3002_two_missing_in_same_currency_still_rejected() {
2940 let elided = |account: &str, currency: &str| Posting {
2941 account: account.into(),
2942 units: Some(rustledger_core::IncompleteAmount::CurrencyOnly(
2943 currency.into(),
2944 )),
2945 cost: None,
2946 price: None,
2947 flag: None,
2948 meta: Default::default(),
2949 comments: vec![],
2950 trailing_comments: vec![],
2951 };
2952
2953 let directives = vec![
2954 Directive::Open(Open::new(date(2024, 1, 1), "Assets:A")),
2955 Directive::Open(Open::new(date(2024, 1, 1), "Assets:B")),
2956 Directive::Open(Open::new(date(2024, 1, 1), "Assets:C")),
2957 Directive::Transaction(
2958 Transaction::new(date(2024, 1, 15), "One currency")
2959 .with_synthesized_posting(elided("Assets:A", "USD"))
2960 .with_synthesized_posting(elided("Assets:B", "USD"))
2961 .with_synthesized_posting(Posting::new(
2962 "Assets:C",
2963 Amount::new(dec!(-600.00), "USD"),
2964 )),
2965 ),
2966 ];
2967
2968 let errors = validate(&directives);
2969
2970 let e3002: Vec<_> = errors
2971 .iter()
2972 .filter(|e| e.code == ErrorCode::MultipleInterpolation)
2973 .collect();
2974 assert_eq!(e3002.len(), 1, "still ambiguous, got: {errors:?}");
2975 assert!(
2976 e3002[0].message.contains("USD"),
2977 "the message should name the contested currency, got: {}",
2978 e3002[0].message
2979 );
2980 }
2981
2982 #[test]
2983 fn test_e7001_unknown_option() {
2984 let state = LedgerState::new();
2986 let mut errors = Vec::new();
2987
2988 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2989
2990 assert_eq!(errors.len(), 1);
2991 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2992 assert!(errors[0].message.contains("bogus_option"));
2993 }
2994
2995 #[test]
2996 fn test_e7002_invalid_option_value() {
2997 let state = LedgerState::new();
2998 let mut errors = Vec::new();
2999
3000 state.import_option_warnings(
3001 &[("E7002", "Invalid leaf account name: 'not-valid'")],
3002 &mut errors,
3003 );
3004
3005 assert_eq!(errors.len(), 1);
3006 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
3007 }
3008
3009 #[test]
3010 fn test_e7003_duplicate_option() {
3011 let state = LedgerState::new();
3012 let mut errors = Vec::new();
3013
3014 state.import_option_warnings(
3015 &[("E7003", "Option \"title\" can only be specified once")],
3016 &mut errors,
3017 );
3018
3019 assert_eq!(errors.len(), 1);
3020 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
3021 }
3022
3023 fn commodity_with_precision(value: MetaValue) -> Directive {
3026 let mut meta = rustledger_core::Metadata::default();
3027 meta.insert("precision".into(), value);
3028 Directive::Commodity(
3029 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
3030 )
3031 }
3032
3033 #[test]
3034 fn precision_meta_valid_integer_emits_no_warning() {
3035 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
3036 let errors = validate(&directives);
3037 assert!(
3038 errors
3039 .iter()
3040 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
3041 "valid precision must not produce a warning, got: {errors:?}"
3042 );
3043 }
3044
3045 #[test]
3046 fn precision_meta_zero_is_valid() {
3047 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
3048 let errors = validate(&directives);
3049 assert!(
3050 errors
3051 .iter()
3052 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
3053 );
3054 }
3055
3056 #[test]
3057 fn precision_meta_negative_emits_e5003() {
3058 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
3059 let errors = validate(&directives);
3060 let warnings: Vec<_> = errors
3061 .iter()
3062 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3063 .collect();
3064 assert_eq!(warnings.len(), 1, "expected one E5003");
3065 assert_eq!(warnings[0].code.severity(), Severity::Warning);
3066 assert!(warnings[0].message.contains("non-negative"));
3067 }
3068
3069 #[test]
3070 fn precision_meta_non_integer_emits_e5003() {
3071 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
3072 let errors = validate(&directives);
3073 let warnings: Vec<_> = errors
3074 .iter()
3075 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3076 .collect();
3077 assert_eq!(warnings.len(), 1);
3078 assert!(warnings[0].message.contains("integer"));
3079 }
3080
3081 #[test]
3082 fn precision_meta_string_value_emits_e5003() {
3083 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
3084 let errors = validate(&directives);
3085 let warnings: Vec<_> = errors
3086 .iter()
3087 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3088 .collect();
3089 assert_eq!(warnings.len(), 1);
3090 assert!(warnings[0].message.contains("string"));
3091 }
3092
3093 #[test]
3094 fn precision_meta_out_of_u32_range_emits_e5003() {
3095 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
3097 8589934592
3098 )))];
3099 let errors = validate(&directives);
3100 let warnings: Vec<_> = errors
3101 .iter()
3102 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3103 .collect();
3104 assert_eq!(warnings.len(), 1);
3105 assert!(warnings[0].message.contains("exceeds"));
3106 }
3107
3108 #[test]
3109 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
3110 let directives = vec![
3115 commodity_with_precision(MetaValue::Number(dec!(2))),
3116 commodity_with_precision(MetaValue::Number(dec!(-1))),
3117 ];
3118 let warnings: Vec<_> = validate(&directives)
3119 .into_iter()
3120 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3121 .collect();
3122 assert_eq!(
3123 warnings.len(),
3124 1,
3125 "exactly one E5003 expected (only the invalid declaration)"
3126 );
3127 assert!(warnings[0].message.contains("non-negative"));
3128 }
3129
3130 #[test]
3131 fn precision_meta_e5003_is_warning_severity() {
3132 assert_eq!(
3136 ErrorCode::InvalidPrecisionMetadata.severity(),
3137 Severity::Warning
3138 );
3139 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
3140 }
3141
3142 #[test]
3150 fn test_validate_early_emits_e1001_on_elided_posting() {
3151 let directives = vec![
3152 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3153 Directive::Transaction(
3154 Transaction::new(date(2024, 1, 15), "Zero to unopened")
3155 .with_synthesized_posting(Posting::new(
3156 "Assets:Bank",
3157 Amount::new(dec!(0.00), "USD"),
3158 ))
3159 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
3160 ),
3161 ];
3162
3163 let session = ValidationSession::new(ValidationOptions::default());
3164 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
3165
3166 assert!(
3167 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
3168 && e.to_string().contains("Expenses:NeverOpened")),
3169 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
3170 );
3171 }
3172
3173 #[test]
3177 fn test_validate_late_does_not_duplicate_e1001() {
3178 let directives = vec![
3179 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3180 Directive::Transaction(
3181 Transaction::new(date(2024, 1, 15), "To unopened")
3182 .with_synthesized_posting(Posting::new(
3183 "Assets:Bank",
3184 Amount::new(dec!(100), "USD"),
3185 ))
3186 .with_synthesized_posting(Posting::new(
3187 "Expenses:NeverOpened",
3188 Amount::new(dec!(-100), "USD"),
3189 )),
3190 ),
3191 ];
3192
3193 let session = ValidationSession::new(ValidationOptions::default());
3194 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
3195 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
3196
3197 let early_e1001 = early
3198 .iter()
3199 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3200 .count();
3201 let late_e1001 = late
3202 .iter()
3203 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3204 .count();
3205
3206 assert_eq!(
3207 early_e1001, 0,
3208 "explicit posting: early phase defers E1001 to late; got: {early:?}"
3209 );
3210 assert_eq!(
3211 late_e1001, 1,
3212 "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
3213 );
3214 }
3215
3216 #[test]
3222 fn test_validate_chained_matches_explicit_phases() {
3223 let directives = vec![
3227 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3228 Directive::Transaction(
3229 Transaction::new(date(2024, 1, 15), "Mixed")
3230 .with_synthesized_posting(Posting::new(
3231 "Assets:Bank",
3232 Amount::new(dec!(50), "USD"),
3233 ))
3234 .with_synthesized_posting(Posting::new(
3235 "Income:Salary",
3236 Amount::new(dec!(-50), "USD"),
3237 )),
3238 ),
3239 Directive::Balance(Balance::new(
3240 date(2024, 1, 16),
3241 "Assets:Bank",
3242 Amount::new(dec!(50), "USD"),
3243 )),
3244 ];
3245
3246 let chained = validate(&directives);
3248
3249 let session = ValidationSession::new(ValidationOptions::default());
3251 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3252 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3253 explicit.extend(late_errs);
3254 explicit.extend(session.finalize());
3255
3256 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3260 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3261 assert_eq!(
3262 chained_strs, explicit_strs,
3263 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3264 );
3265 }
3266
3267 #[test]
3268 fn test_phase_order_early_then_late_then_finalize() {
3269 let directives = vec![
3276 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3277 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3278 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3279 Directive::Transaction(
3281 Transaction::new(date(2024, 1, 5), "early")
3282 .with_synthesized_posting(Posting::new(
3283 "Assets:Bank",
3284 Amount::new(dec!(100), "USD"),
3285 ))
3286 .with_synthesized_posting(Posting::new(
3287 "Income:Salary",
3288 Amount::new(dec!(-100), "USD"),
3289 )),
3290 ),
3291 Directive::Pad(Pad::new(
3293 date(2024, 1, 10),
3294 "Assets:Other",
3295 "Equity:Opening",
3296 )),
3297 Directive::Balance(Balance::new(
3299 date(2024, 2, 1),
3300 "Assets:Bank",
3301 Amount::new(dec!(999), "USD"),
3302 )),
3303 ];
3304
3305 let errors = validate(&directives);
3306 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3307
3308 let early_pos = codes
3309 .iter()
3310 .position(|c| *c == ErrorCode::AccountNotOpen)
3311 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3312 let late_pos = codes
3313 .iter()
3314 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3315 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3316 let finalize_pos = codes
3317 .iter()
3318 .position(|c| *c == ErrorCode::PadWithoutBalance)
3319 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3320
3321 assert!(
3322 early_pos < late_pos,
3323 "early-phase errors must precede late-phase; got {codes:?}"
3324 );
3325 assert!(
3326 late_pos < finalize_pos,
3327 "late-phase errors must precede finalize; got {codes:?}"
3328 );
3329 }
3330
3331 #[test]
3332 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3333 let directives = vec![
3340 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3341 Directive::Transaction(
3344 Transaction::new(date(2024, 1, 10), "leave residue")
3345 .with_synthesized_posting(Posting::new(
3346 "Assets:Bank",
3347 Amount::new(dec!(50), "USD"),
3348 ))
3349 .with_synthesized_posting(Posting::new(
3350 "Equity:Opening",
3351 Amount::new(dec!(-50), "USD"),
3352 )),
3353 ),
3354 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3355 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3356 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3357 ];
3358
3359 let errors = validate(&directives);
3360 let close_not_empty_count = errors
3361 .iter()
3362 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3363 .count();
3364 assert_eq!(
3365 close_not_empty_count, 1,
3366 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3367 );
3368 let account_closed_count = errors
3370 .iter()
3371 .filter(|e| e.code == ErrorCode::AccountClosed)
3372 .count();
3373 assert_eq!(
3374 account_closed_count, 1,
3375 "duplicate close should still report AccountClosed once; got {errors:?}"
3376 );
3377 }
3378
3379 #[test]
3405 fn typestate_pins_phase_ordering_at_compile_time() {
3406 fn _expect_pending_returns_early(
3418 s: ValidationSession<Pending>,
3419 ) -> ValidationSession<EarlyDone> {
3420 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3421 s
3422 }
3423 fn _expect_early_returns_late(
3424 s: ValidationSession<EarlyDone>,
3425 ) -> ValidationSession<LateDone> {
3426 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3427 s
3428 }
3429 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3430 s.finalize()
3431 }
3432 }
3433
3434 fn open_at(d: NaiveDate, account: &str) -> Directive {
3443 Directive::Open(Open::new(d, account))
3444 }
3445
3446 fn txn_at(d: NaiveDate, postings: Vec<Posting>) -> Directive {
3452 let mut t = Transaction::new(d, "t");
3453 for (i, p) in postings.into_iter().enumerate() {
3454 let start = (d.day() as usize) * 100 + i * 10;
3455 t = t.with_posting(rustledger_core::Spanned::new(
3456 p,
3457 rustledger_core::Span::new(start, start + 9),
3458 ));
3459 }
3460 Directive::Transaction(t)
3461 }
3462
3463 #[test]
3464 fn explicit_posting_before_open_is_flagged_exactly_once() {
3465 let directives = vec![
3466 open_at(date(2020, 1, 1), "Equity:Opening"),
3467 txn_at(
3468 date(2020, 1, 15),
3469 vec![
3470 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3471 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3472 ],
3473 ),
3474 open_at(date(2020, 2, 1), "Assets:Bank"),
3475 ];
3476 let errors = validate(&directives);
3477 let hits: Vec<_> = errors
3478 .iter()
3479 .filter(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank"))
3480 .collect();
3481 assert_eq!(
3482 hits.len(),
3483 1,
3484 "use-before-open must be reported exactly once: {errors:?}"
3485 );
3486 assert!(
3487 hits[0].message.contains("not opened until 2020-02-01"),
3488 "error should carry the open date: {}",
3489 hits[0].message
3490 );
3491 }
3492
3493 #[test]
3494 fn elided_posting_before_open_is_flagged_exactly_once() {
3495 let directives = vec![
3498 open_at(date(2020, 1, 1), "Equity:Opening"),
3499 txn_at(
3500 date(2020, 1, 15),
3501 vec![
3502 Posting {
3503 account: "Assets:Bank".into(),
3504 units: None,
3505 cost: None,
3506 price: None,
3507 flag: None,
3508 meta: Default::default(),
3509 comments: vec![],
3510 trailing_comments: vec![],
3511 },
3512 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3513 ],
3514 ),
3515 open_at(date(2020, 2, 1), "Assets:Bank"),
3516 ];
3517 let errors = validate(&directives);
3518 let hits = errors
3519 .iter()
3520 .filter(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank"))
3521 .count();
3522 assert_eq!(
3523 hits, 1,
3524 "elided-before-open must not double-report: {errors:?}"
3525 );
3526 }
3527
3528 #[test]
3529 fn posting_after_close_is_flagged_exactly_once() {
3530 let directives = vec![
3534 open_at(date(2020, 1, 1), "Assets:Bank"),
3535 open_at(date(2020, 1, 1), "Equity:Opening"),
3536 Directive::Close(Close::new(date(2020, 2, 1), "Assets:Bank")),
3537 txn_at(
3538 date(2020, 3, 1),
3539 vec![
3540 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3541 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3542 ],
3543 ),
3544 ];
3545 let errors = validate(&directives);
3546 let hits = errors
3547 .iter()
3548 .filter(|e| e.code == ErrorCode::AccountClosed)
3549 .count();
3550 assert_eq!(
3551 hits, 1,
3552 "after-close must be reported exactly once: {errors:?}"
3553 );
3554 }
3555
3556 #[test]
3557 fn synthesized_postings_are_not_lifecycle_deferred() {
3558 let mut t = Transaction::new(date(2020, 1, 15), "synth");
3567 t = t.with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
3568 t = t.with_synthesized_posting(Posting::new(
3569 "Equity:Opening",
3570 Amount::new(dec!(-100), "USD"),
3571 ));
3572 let directives = vec![
3573 open_at(date(2020, 1, 1), "Equity:Opening"),
3574 Directive::Transaction(t),
3575 open_at(date(2020, 2, 1), "Assets:Bank"),
3576 ];
3577 let errors = validate(&directives);
3578 assert!(
3579 !errors
3580 .iter()
3581 .any(|e| e.code == ErrorCode::AccountNotOpen
3582 && e.message.contains("not opened until")),
3583 "synthesized postings must not arm the late lifecycle check: {errors:?}"
3584 );
3585 }
3586
3587 #[test]
3588 fn posting_on_and_after_open_date_is_clean() {
3589 let directives = vec![
3590 open_at(date(2020, 1, 1), "Assets:Bank"),
3591 open_at(date(2020, 1, 1), "Equity:Opening"),
3592 txn_at(
3593 date(2020, 1, 1),
3594 vec![
3595 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3596 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3597 ],
3598 ),
3599 ];
3600 let errors = validate(&directives);
3601 assert!(
3602 !errors
3603 .iter()
3604 .any(|e| matches!(e.code, ErrorCode::AccountNotOpen | ErrorCode::AccountClosed)),
3605 "same-date use must be clean: {errors:?}"
3606 );
3607 }
3608}
3609
3610#[cfg(test)]
3611mod budget_validation_tests {
3612 use super::*;
3613
3614 fn custom(date: NaiveDate, values: Vec<rustledger_core::MetaValue>) -> rustledger_core::Custom {
3615 rustledger_core::Custom {
3616 date,
3617 custom_type: "budget".to_string(),
3618 values,
3619 meta: rustledger_core::Metadata::default(),
3620 }
3621 }
3622
3623 fn d() -> NaiveDate {
3624 rustledger_core::naive_date(2024, 1, 1).unwrap()
3625 }
3626
3627 #[test]
3630 fn a_malformed_budget_is_reported_as_a_warning() {
3631 use rustledger_core::{Amount, Currency, MetaValue};
3632 let mut errors = Vec::new();
3633 validate_budget_custom(
3634 &custom(
3635 d(),
3636 vec![
3637 MetaValue::Account(rustledger_core::Account::new("Expenses:Food")),
3638 MetaValue::String("fortnightly".to_string()),
3639 MetaValue::Amount(Amount {
3640 number: Decimal::from(400),
3641 currency: Currency::new("USD"),
3642 }),
3643 ],
3644 ),
3645 &mut errors,
3646 );
3647 assert_eq!(errors.len(), 1, "{errors:?}");
3648 assert_eq!(errors[0].code, ErrorCode::MalformedBudget);
3649 assert_eq!(errors[0].code.code(), "E11001");
3650 assert_eq!(errors[0].code.severity(), Severity::Warning);
3653 assert!(errors[0].message.contains("fortnightly"), "{errors:?}");
3654 }
3655
3656 #[test]
3658 fn well_formed_and_unrelated_customs_are_silent() {
3659 use rustledger_core::{Amount, Currency, MetaValue};
3660 let ok = custom(
3661 d(),
3662 vec![
3663 MetaValue::Account(rustledger_core::Account::new("Expenses:Food")),
3664 MetaValue::String("monthly".to_string()),
3665 MetaValue::Amount(Amount {
3666 number: Decimal::from(400),
3667 currency: Currency::new("USD"),
3668 }),
3669 ],
3670 );
3671 let mut errors = Vec::new();
3672 validate_budget_custom(&ok, &mut errors);
3673 assert!(errors.is_empty(), "{errors:?}");
3674
3675 let mut other = ok;
3676 other.custom_type = "autopay".to_string();
3677 other.values = vec![MetaValue::String("anything at all".to_string())];
3678 let mut errors = Vec::new();
3679 validate_budget_custom(&other, &mut errors);
3680 assert!(
3681 errors.is_empty(),
3682 "another tool's custom type is none of our business: {errors:?}"
3683 );
3684 }
3685}