1#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47mod error;
48mod validators;
49
50pub use error::{ErrorCode, Severity, ValidationError, is_advisory_only_code};
51pub use validators::balance::balance_tolerance;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Phase {
80 Early,
84 Late,
88}
89
90use validators::{
91 register_open_late, validate_balance_early, validate_balance_late, validate_close,
92 validate_close_late, validate_document, validate_note, validate_open, validate_pad,
93 validate_transaction_early, validate_transaction_late,
94};
95
96use rayon::prelude::*;
97use rustledger_core::NaiveDate;
98
99const PARALLEL_SORT_THRESHOLD: usize = 5000;
102
103const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
107use rust_decimal::Decimal;
108use rustc_hash::{FxHashMap, FxHashSet};
109use rustledger_core::{Account, BookingMethod, Commodity, Currency, Directive, Inventory};
110use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
111use std::collections::BTreeSet;
112
113#[derive(Debug, Clone)]
115struct AccountState {
116 opened: NaiveDate,
118 closed: Option<NaiveDate>,
120 currencies: FxHashSet<rustledger_core::Currency>,
122 booking: BookingMethod,
125}
126
127#[non_exhaustive]
129#[derive(Debug, Clone)]
130pub struct ValidationOptions {
131 pub require_commodities: bool,
133 pub check_documents: bool,
135 pub warn_future_dates: bool,
137 pub document_base: Option<std::path::PathBuf>,
139 pub document_dirs: Vec<std::path::PathBuf>,
143 pub document_source_dirs: Vec<std::path::PathBuf>,
152 pub account_types: Vec<String>,
155 pub infer_tolerance_from_cost: bool,
158 pub tolerance_multiplier: Decimal,
161 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
164 pub default_booking_method: BookingMethod,
174}
175
176impl Default for ValidationOptions {
177 fn default() -> Self {
178 Self {
179 require_commodities: false,
180 check_documents: true, warn_future_dates: false,
182 document_base: None,
183 document_dirs: Vec::new(),
184 document_source_dirs: Vec::new(),
185 account_types: vec![
186 "Assets".to_string(),
187 "Liabilities".to_string(),
188 "Equity".to_string(),
189 "Income".to_string(),
190 "Expenses".to_string(),
191 ],
192 infer_tolerance_from_cost: false,
194 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
196 default_booking_method: BookingMethod::default(),
197 }
198 }
199}
200
201impl ValidationOptions {
202 #[must_use]
204 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
205 self.account_types = types;
206 self
207 }
208
209 #[must_use]
211 pub const fn with_require_commodities(mut self, require: bool) -> Self {
212 self.require_commodities = require;
213 self
214 }
215
216 #[must_use]
218 pub const fn with_check_documents(mut self, check: bool) -> Self {
219 self.check_documents = check;
220 self
221 }
222
223 #[must_use]
225 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
226 self.warn_future_dates = warn;
227 self
228 }
229
230 #[must_use]
232 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
233 self.document_dirs = dirs;
234 self
235 }
236
237 #[must_use]
241 pub fn with_document_source_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
242 self.document_source_dirs = dirs;
243 self
244 }
245
246 #[must_use]
248 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
249 self.infer_tolerance_from_cost = infer;
250 self
251 }
252
253 #[must_use]
255 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
256 self.tolerance_multiplier = multiplier;
257 self
258 }
259
260 #[must_use]
262 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
263 self.inferred_tolerance_default = defaults;
264 self
265 }
266
267 #[must_use]
272 pub const fn with_default_booking_method(mut self, method: BookingMethod) -> Self {
273 self.default_booking_method = method;
274 self
275 }
276}
277
278#[derive(Debug, Clone)]
280struct PendingPad {
281 source_account: rustledger_core::Account,
283 date: NaiveDate,
285 padded_currencies: FxHashSet<rustledger_core::Currency>,
292 location: Option<(rustledger_parser::Span, u16)>,
296}
297
298#[derive(Debug, Clone)]
306pub struct BalanceActual {
307 pub date: NaiveDate,
309 pub account: Account,
311 pub currency: rustledger_core::Currency,
313 pub diff: rustledger_core::Decimal,
315}
316
317#[derive(Debug, Default)]
319pub struct LedgerState {
320 accounts: FxHashMap<rustledger_core::Account, AccountState>,
322 inventories: FxHashMap<rustledger_core::Account, Inventory>,
324 inventory_accounts: BTreeSet<Account>,
331 commodities: FxHashSet<rustledger_core::Currency>,
333 pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
335 options: ValidationOptions,
337 last_date: Option<NaiveDate>,
339 pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
350 pub(crate) account_not_open_early: FxHashSet<(u16, rustledger_core::Span)>,
362 pub(crate) balance_actuals: Vec<BalanceActual>,
366}
367
368impl LedgerState {
369 #[must_use]
371 pub fn new() -> Self {
372 Self::default()
373 }
374
375 #[must_use]
377 pub fn with_options(options: ValidationOptions) -> Self {
378 Self {
379 options,
380 ..Default::default()
381 }
382 }
383
384 pub const fn set_require_commodities(&mut self, require: bool) {
386 self.options.require_commodities = require;
387 }
388
389 pub const fn set_check_documents(&mut self, check: bool) {
391 self.options.check_documents = check;
392 }
393
394 pub const fn set_warn_future_dates(&mut self, warn: bool) {
396 self.options.warn_future_dates = warn;
397 }
398
399 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
401 self.options.document_base = Some(base.into());
402 }
403
404 #[must_use]
406 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
407 self.inventories.get(account)
408 }
409
410 pub fn accounts(&self) -> impl Iterator<Item = &str> {
412 self.accounts.keys().map(rustledger_core::Account::as_str)
413 }
414
415 pub fn import_option_warnings(
423 &self,
424 warnings: &[(&str, &str)],
425 errors: &mut Vec<ValidationError>,
426 ) {
427 for &(code, message) in warnings {
428 let error_code = match code {
429 "E7001" => ErrorCode::UnknownOption,
430 "E7002" => ErrorCode::InvalidOptionValue,
431 "E7003" => ErrorCode::DuplicateOption,
432 _ => continue,
433 };
434 errors.push(ValidationError::new(
435 error_code,
436 message.to_string(),
437 NaiveDate::default(),
439 ));
440 }
441 }
442}
443
444trait ValidatableDirective: Sync {
453 fn directive(&self) -> &Directive;
454 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
458}
459
460impl ValidatableDirective for Directive {
461 fn directive(&self) -> &Directive {
462 self
463 }
464 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
465 None
466 }
467}
468
469impl ValidatableDirective for Spanned<Directive> {
470 fn directive(&self) -> &Directive {
471 &self.value
472 }
473 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
474 Some((self.span, self.file_id))
475 }
476}
477
478fn sum_account_subtree(
492 inventories: &FxHashMap<Account, Inventory>,
493 index: &BTreeSet<Account>,
494 account: &Account,
495 currency: &Currency,
496) -> Decimal {
497 let acct = account.as_str();
498 let mut total = inventories
500 .get(account)
501 .map_or(Decimal::ZERO, |inv| inv.units(currency));
502 let mut lower = String::with_capacity(acct.len() + 1);
507 lower.push_str(acct);
508 lower.push(':');
509 let mut upper = String::with_capacity(acct.len() + 1);
510 upper.push_str(acct);
511 upper.push(';');
512 let bounds = (
513 std::ops::Bound::Included(lower.as_str()),
514 std::ops::Bound::Excluded(upper.as_str()),
515 );
516 for sub in index.range::<str, _>(bounds) {
517 if let Some(inv) = inventories.get(sub) {
518 total += inv.units(currency);
519 }
520 }
521 total
522}
523
524fn validate_phase_inner<D: ValidatableDirective>(
535 directives: &[D],
536 state: &mut LedgerState,
537 phase: Phase,
538 today: NaiveDate,
539) -> Vec<ValidationError> {
540 let document_exists_cache = if phase == Phase::Early {
543 build_document_exists_cache(directives, &state.options)
544 } else {
545 FxHashMap::default()
546 };
547
548 if phase == Phase::Early {
552 state.last_date = None;
553 }
554
555 let mut errors = Vec::new();
556
557 let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
562 sorted.extend(directives.iter());
563 let sort_fn = |a: &&D, b: &&D| {
564 let ad = a.directive();
565 let bd = b.directive();
566 ad.date()
567 .cmp(&bd.date())
568 .then_with(|| ad.priority().cmp(&bd.priority()))
569 .then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
570 };
571 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
572 sorted.par_sort_by(sort_fn);
573 } else {
574 sorted.sort_by(sort_fn);
575 }
576
577 for d in sorted {
578 let directive = d.directive();
579 let date = directive.date();
580
581 let error_count_before = errors.len();
588
589 if phase == Phase::Early {
593 if let Some(last) = state.last_date
594 && date < last
595 {
596 errors.push(ValidationError::new(
597 ErrorCode::DateOutOfOrder,
598 format!("Directive date {date} is before previous directive {last}"),
599 date,
600 ));
601 }
602 state.last_date = Some(date);
603
604 if state.options.warn_future_dates && date > today {
605 errors.push(ValidationError::new(
606 ErrorCode::FutureDate,
607 format!("Entry dated in the future: {date}"),
608 date,
609 ));
610 }
611 }
612
613 match (phase, directive) {
614 (Phase::Early, Directive::Open(open)) => {
616 validate_open(state, open, &mut errors);
617 }
618 (Phase::Late, Directive::Open(open)) => {
622 register_open_late(state, open);
623 }
624 (Phase::Early, Directive::Close(close)) => {
625 validate_close(state, close, &mut errors);
626 }
627 (Phase::Late, Directive::Close(close)) => {
628 validate_close_late(state, close, &mut errors);
629 }
630 (Phase::Early, Directive::Commodity(comm)) => {
631 state.commodities.insert(comm.currency.clone());
632 validate_commodity_precision_meta(comm, &mut errors);
633 }
634 (Phase::Early, Directive::Pad(pad)) => {
635 validate_pad(state, pad, d.span_info(), &mut errors);
636 }
637 (Phase::Early, Directive::Document(doc)) => {
638 let file_id = d.span_info().map(|(_, fid)| fid);
639 validate_document(state, doc, file_id, &document_exists_cache, &mut errors);
640 }
641 (Phase::Early, Directive::Note(note)) => {
642 validate_note(state, note, &mut errors);
643 }
644 (Phase::Early, Directive::Transaction(txn)) => {
646 validate_transaction_early(state, txn, &mut errors);
647 }
648 (Phase::Late, Directive::Transaction(txn)) => {
649 validate_transaction_late(state, txn, &mut errors);
650 }
651 (Phase::Early, Directive::Balance(bal)) => {
652 validate_balance_early(state, bal, &mut errors);
653 }
654 (Phase::Late, Directive::Balance(bal)) => {
655 validate_balance_late(state, bal, &mut errors);
656 }
657 _ => {}
659 }
660
661 if let Some((span, file_id)) = d.span_info() {
668 for error in errors.iter_mut().skip(error_count_before) {
669 if error.span.is_none() {
670 error.span = Some(span);
671 error.file_id = Some(file_id);
672 }
673 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
674 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
675 }
676 }
677 }
678 }
679
680 errors
681}
682
683const SYNTHESIZED_DIRECTIVE_NOTE: &str = "directive was synthesized by a plugin (no source location \
691 in your files); the responsible plugin is either an \
692 enabled auto-plugin (e.g. `auto_accounts`, or document \
693 discovery via `option \"documents\"`) or one of your \
694 `plugin \"…\"` declarations";
695
696fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
697 let mut errors = Vec::new();
698 for (target_account, pads) in &state.pending_pads {
699 for pad in pads {
700 if pad.padded_currencies.is_empty() {
701 let mut error = ValidationError::new(
702 ErrorCode::PadWithoutBalance,
703 "Unused Pad entry".to_string(),
704 pad.date,
705 )
706 .with_context(format!(
707 " {} pad {} {}",
708 pad.date, target_account, pad.source_account
709 ));
710 if let Some((span, file_id)) = pad.location {
716 error.span = Some(span);
717 error.file_id = Some(file_id);
718 if file_id == SYNTHESIZED_FILE_ID {
719 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
720 }
721 }
722 errors.push(error);
723 }
724 }
725 }
726 errors
727}
728
729fn build_document_exists_cache<'a, D: ValidatableDirective>(
751 directives: &'a [D],
752 options: &ValidationOptions,
753) -> FxHashMap<(&'a str, Option<u16>), bool> {
754 if !options.check_documents {
755 return FxHashMap::default();
756 }
757
758 let mut keys: FxHashSet<(&str, Option<u16>)> = FxHashSet::default();
764 for d in directives {
765 if let Directive::Document(doc) = d.directive() {
766 let file_id = d.span_info().map(|(_, fid)| fid);
767 keys.insert((doc.path.as_str(), file_id));
768 }
769 }
770 let keys: Vec<(&str, Option<u16>)> = keys.into_iter().collect();
771
772 let resolve = |(s, file_id): (&'a str, Option<u16>)| {
779 ((s, file_id), document_file_exists(s, file_id, options))
780 };
781
782 if keys.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
783 keys.into_par_iter().map(resolve).collect()
784 } else {
785 keys.into_iter().map(resolve).collect()
786 }
787}
788
789fn document_file_exists(path: &str, file_id: Option<u16>, options: &ValidationOptions) -> bool {
799 let doc_path = std::path::Path::new(path);
800 if doc_path.is_absolute() {
801 doc_path.exists()
802 } else if let Some(base) = &options.document_base {
803 base.join(doc_path).exists()
804 } else if !options.document_dirs.is_empty() {
805 options
806 .document_dirs
807 .iter()
808 .any(|dir| dir.join(doc_path).exists())
809 } else if let Some(dir) = file_id.and_then(|id| options.document_source_dirs.get(id as usize)) {
810 dir.join(doc_path).exists()
811 } else {
812 doc_path.exists()
813 }
814}
815
816pub mod phase {
861 mod sealed {
862 pub trait Sealed {}
863 }
864
865 pub trait SessionPhase: sealed::Sealed {}
868
869 macro_rules! define_phase {
870 ($name:ident, $doc:expr) => {
871 #[doc = $doc]
872 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
873 pub struct $name;
874 impl sealed::Sealed for $name {}
875 impl SessionPhase for $name {}
876 };
877 }
878
879 define_phase!(
880 Pending,
881 "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
882 );
883 define_phase!(
884 EarlyDone,
885 "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
886 );
887 define_phase!(
888 LateDone,
889 "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
890 );
891}
892
893pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
894
895pub struct ValidationSession<P: SessionPhase = Pending> {
968 state: LedgerState,
969 _phase: std::marker::PhantomData<P>,
970}
971
972impl<P: SessionPhase> ValidationSession<P> {
973 #[must_use]
979 pub fn balance_actuals(&self) -> &[BalanceActual] {
980 &self.state.balance_actuals
981 }
982}
983
984impl ValidationSession<Pending> {
985 #[must_use]
990 pub fn new(options: ValidationOptions) -> Self {
991 Self {
992 state: LedgerState::with_options(options),
993 _phase: std::marker::PhantomData,
994 }
995 }
996
997 #[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"]
1008 pub fn run_early(
1009 self,
1010 directives: &[Directive],
1011 today: NaiveDate,
1012 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1013 self.run_phase_internal(directives, Phase::Early, today)
1014 }
1015
1016 #[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"]
1020 pub fn run_early_spanned(
1021 self,
1022 directives: &[Spanned<Directive>],
1023 today: NaiveDate,
1024 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1025 self.run_phase_internal(directives, Phase::Early, today)
1026 }
1027
1028 fn run_phase_internal<D: ValidatableDirective>(
1036 mut self,
1037 directives: &[D],
1038 phase: Phase,
1039 today: NaiveDate,
1040 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1041 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1042 (
1043 ValidationSession {
1044 state: self.state,
1045 _phase: std::marker::PhantomData,
1046 },
1047 errors,
1048 )
1049 }
1050}
1051
1052impl ValidationSession<EarlyDone> {
1053 #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1063 pub fn run_late(
1064 self,
1065 directives: &[Directive],
1066 today: NaiveDate,
1067 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1068 self.run_phase_internal(directives, Phase::Late, today)
1069 }
1070
1071 #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1075 pub fn run_late_spanned(
1076 self,
1077 directives: &[Spanned<Directive>],
1078 today: NaiveDate,
1079 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1080 self.run_phase_internal(directives, Phase::Late, today)
1081 }
1082
1083 fn run_phase_internal<D: ValidatableDirective>(
1087 mut self,
1088 directives: &[D],
1089 phase: Phase,
1090 today: NaiveDate,
1091 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1092 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1093 (
1094 ValidationSession {
1095 state: self.state,
1096 _phase: std::marker::PhantomData,
1097 },
1098 errors,
1099 )
1100 }
1101}
1102
1103impl ValidationSession<LateDone> {
1104 #[must_use]
1108 pub fn finalize(self) -> Vec<ValidationError> {
1109 check_unused_pads(&self.state)
1110 }
1111}
1112
1113fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
1119 let Some(value) = comm.meta.get("precision") else {
1120 return;
1121 };
1122 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
1123 errors.push(ValidationError::new(
1124 ErrorCode::InvalidPrecisionMetadata,
1125 format!(
1126 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
1127 comm.currency
1128 ),
1129 comm.date,
1130 ));
1131 }
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136 use super::*;
1137 use rust_decimal_macros::dec;
1138 use rustledger_core::{
1139 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
1140 };
1141
1142 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1143 rustledger_core::naive_date(year, month, day).unwrap()
1144 }
1145
1146 fn test_today() -> NaiveDate {
1150 date(2030, 1, 1)
1151 }
1152
1153 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
1158 validate_with_options(directives, ValidationOptions::default())
1159 }
1160
1161 fn validate_with_options(
1164 directives: &[Directive],
1165 options: ValidationOptions,
1166 ) -> Vec<ValidationError> {
1167 validate_with_today(directives, options, test_today())
1168 }
1169
1170 fn validate_with_today(
1174 directives: &[Directive],
1175 options: ValidationOptions,
1176 today: NaiveDate,
1177 ) -> Vec<ValidationError> {
1178 let session = ValidationSession::new(options);
1179 let (session, mut errors) = session.run_early(directives, today);
1180 let (session, late_errors) = session.run_late(directives, today);
1181 errors.extend(late_errors);
1182 errors.extend(session.finalize());
1183 errors
1184 }
1185
1186 #[test]
1187 fn sum_account_subtree_matches_scan_and_excludes_prefix_siblings() {
1188 let mut state = LedgerState::default();
1190 let fixture = [
1191 ("Assets:Bank", dec!(10)),
1192 ("Assets:Bank:Checking", dec!(40)),
1193 ("Assets:Bank:Savings", dec!(5)),
1194 ("Assets:BankAlias", dec!(99)), ("Assets:Other", dec!(7)),
1196 ];
1197 for (name, amt) in fixture {
1198 let acct = Account::from(name);
1199 let mut inv = Inventory::new();
1200 inv.add(rustledger_core::Position::simple(Amount::new(amt, "USD")));
1201 state.inventories.insert(acct.clone(), inv);
1202 state.inventory_accounts.insert(acct);
1203 }
1204
1205 let cur = Currency::from("USD");
1206 for name in [
1208 "Assets:Bank",
1209 "Assets:Bank:Checking",
1210 "Assets:BankAlias",
1211 "Assets:Other",
1212 "Assets:Missing",
1213 ] {
1214 let acct = Account::from(name);
1215 let indexed =
1216 sum_account_subtree(&state.inventories, &state.inventory_accounts, &acct, &cur);
1217 let scan =
1218 rustledger_core::sum_account_and_subaccounts(state.inventories.iter(), name, &cur);
1219 assert_eq!(indexed, scan, "indexed vs scan disagree for {name}");
1220 }
1221
1222 let bank = sum_account_subtree(
1224 &state.inventories,
1225 &state.inventory_accounts,
1226 &Account::from("Assets:Bank"),
1227 &cur,
1228 );
1229 assert_eq!(
1230 bank,
1231 dec!(55),
1232 "Assets:Bank must sum its subtree, excluding the Assets:BankAlias prefix sibling"
1233 );
1234 }
1235
1236 #[test]
1237 fn test_validate_account_lifecycle() {
1238 let directives = vec![
1239 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1240 Directive::Transaction(
1241 Transaction::new(date(2024, 1, 15), "Test")
1242 .with_synthesized_posting(Posting::new(
1243 "Assets:Bank",
1244 Amount::new(dec!(100), "USD"),
1245 ))
1246 .with_synthesized_posting(Posting::new(
1247 "Income:Salary",
1248 Amount::new(dec!(-100), "USD"),
1249 )),
1250 ),
1251 ];
1252
1253 let errors = validate(&directives);
1254
1255 assert!(errors
1257 .iter()
1258 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1259 }
1260
1261 #[test]
1262 fn test_validate_account_used_before_open() {
1263 let directives = vec![
1264 Directive::Transaction(
1265 Transaction::new(date(2024, 1, 1), "Test")
1266 .with_synthesized_posting(Posting::new(
1267 "Assets:Bank",
1268 Amount::new(dec!(100), "USD"),
1269 ))
1270 .with_synthesized_posting(Posting::new(
1271 "Income:Salary",
1272 Amount::new(dec!(-100), "USD"),
1273 )),
1274 ),
1275 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1276 ];
1277
1278 let errors = validate(&directives);
1279
1280 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1281 }
1282
1283 #[test]
1284 fn test_validate_account_used_after_close() {
1285 let directives = vec![
1286 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1287 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1288 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1289 Directive::Transaction(
1290 Transaction::new(date(2024, 7, 1), "Test")
1291 .with_synthesized_posting(Posting::new(
1292 "Assets:Bank",
1293 Amount::new(dec!(-50), "USD"),
1294 ))
1295 .with_synthesized_posting(Posting::new(
1296 "Expenses:Food",
1297 Amount::new(dec!(50), "USD"),
1298 )),
1299 ),
1300 ];
1301
1302 let errors = validate(&directives);
1303
1304 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1305 }
1306
1307 #[test]
1308 fn test_validate_balance_assertion() {
1309 let directives = vec![
1310 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1311 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1312 Directive::Transaction(
1313 Transaction::new(date(2024, 1, 15), "Deposit")
1314 .with_synthesized_posting(Posting::new(
1315 "Assets:Bank",
1316 Amount::new(dec!(1000.00), "USD"),
1317 ))
1318 .with_synthesized_posting(Posting::new(
1319 "Income:Salary",
1320 Amount::new(dec!(-1000.00), "USD"),
1321 )),
1322 ),
1323 Directive::Balance(Balance::new(
1324 date(2024, 1, 16),
1325 "Assets:Bank",
1326 Amount::new(dec!(1000.00), "USD"),
1327 )),
1328 ];
1329
1330 let errors = validate(&directives);
1331 assert!(errors.is_empty(), "{errors:?}");
1332 }
1333
1334 #[test]
1335 fn test_validate_balance_assertion_failed() {
1336 let directives = vec![
1337 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1338 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1339 Directive::Transaction(
1340 Transaction::new(date(2024, 1, 15), "Deposit")
1341 .with_synthesized_posting(Posting::new(
1342 "Assets:Bank",
1343 Amount::new(dec!(1000.00), "USD"),
1344 ))
1345 .with_synthesized_posting(Posting::new(
1346 "Income:Salary",
1347 Amount::new(dec!(-1000.00), "USD"),
1348 )),
1349 ),
1350 Directive::Balance(Balance::new(
1351 date(2024, 1, 16),
1352 "Assets:Bank",
1353 Amount::new(dec!(500.00), "USD"), )),
1355 ];
1356
1357 let errors = validate(&directives);
1358 assert!(
1359 errors
1360 .iter()
1361 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1362 );
1363 }
1364
1365 #[test]
1371 fn test_validate_balance_assertion_within_tolerance() {
1372 let directives = vec![
1377 Directive::Open(
1378 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1379 ),
1380 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1381 Directive::Transaction(
1382 Transaction::new(date(2024, 1, 15), "Deposit")
1383 .with_synthesized_posting(Posting::new(
1384 "Assets:Bank",
1385 Amount::new(dec!(70.538), "ABC"), ))
1387 .with_synthesized_posting(Posting::new(
1388 "Expenses:Misc",
1389 Amount::new(dec!(-70.538), "ABC"),
1390 )),
1391 ),
1392 Directive::Balance(Balance::new(
1393 date(2024, 1, 16),
1394 "Assets:Bank",
1395 Amount::new(dec!(70.53), "ABC"), )),
1397 ];
1398
1399 let errors = validate(&directives);
1400 assert!(
1401 errors.is_empty(),
1402 "Balance within tolerance should pass: {errors:?}"
1403 );
1404 }
1405
1406 #[test]
1408 fn test_validate_balance_assertion_exceeds_tolerance() {
1409 let directives = vec![
1414 Directive::Open(
1415 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1416 ),
1417 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1418 Directive::Transaction(
1419 Transaction::new(date(2024, 1, 15), "Deposit")
1420 .with_synthesized_posting(Posting::new(
1421 "Assets:Bank",
1422 Amount::new(dec!(70.542), "ABC"),
1423 ))
1424 .with_synthesized_posting(Posting::new(
1425 "Expenses:Misc",
1426 Amount::new(dec!(-70.542), "ABC"),
1427 )),
1428 ),
1429 Directive::Balance(Balance::new(
1430 date(2024, 1, 16),
1431 "Assets:Bank",
1432 Amount::new(dec!(70.53), "ABC"), )),
1434 ];
1435
1436 let errors = validate(&directives);
1437 assert!(
1438 errors
1439 .iter()
1440 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1441 "Balance exceeding tolerance should fail"
1442 );
1443 }
1444
1445 #[test]
1446 fn test_validate_unbalanced_transaction() {
1447 let directives = vec![
1448 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1449 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1450 Directive::Transaction(
1451 Transaction::new(date(2024, 1, 15), "Unbalanced")
1452 .with_synthesized_posting(Posting::new(
1453 "Assets:Bank",
1454 Amount::new(dec!(-50.00), "USD"),
1455 ))
1456 .with_synthesized_posting(Posting::new(
1457 "Expenses:Food",
1458 Amount::new(dec!(40.00), "USD"),
1459 )), ),
1461 ];
1462
1463 let errors = validate(&directives);
1464 assert!(
1465 errors
1466 .iter()
1467 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1468 );
1469 }
1470
1471 #[test]
1472 fn test_validate_currency_not_allowed() {
1473 let directives = vec![
1474 Directive::Open(
1475 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1476 ),
1477 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1478 Directive::Transaction(
1479 Transaction::new(date(2024, 1, 15), "Test")
1480 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_synthesized_posting(Posting::new(
1482 "Income:Salary",
1483 Amount::new(dec!(-100.00), "EUR"),
1484 )),
1485 ),
1486 ];
1487
1488 let errors = validate(&directives);
1489 assert!(
1490 errors
1491 .iter()
1492 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1493 );
1494 }
1495
1496 #[test]
1497 fn test_validate_balance_wrong_currency() {
1498 let directives = vec![
1501 Directive::Open(
1502 Open::new(date(2024, 1, 1), "Assets:Cash").with_currencies(vec!["USD".into()]),
1503 ),
1504 Directive::Balance(Balance::new(
1505 date(2024, 3, 1),
1506 "Assets:Cash",
1507 Amount::new(dec!(100), "EUR"),
1508 )),
1509 ];
1510 let errors = validate(&directives);
1511 assert!(
1512 errors
1513 .iter()
1514 .any(|e| e.code == ErrorCode::CurrencyNotAllowed
1515 && e.message.contains("for Balance directive")),
1516 "balance in a non-allowed currency should be flagged (#1668); got: {:?}",
1517 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1518 );
1519 }
1520
1521 #[test]
1522 fn test_validate_balance_currency_allowed_when_unconstrained() {
1523 let directives = vec![
1526 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1527 Directive::Balance(Balance::new(
1528 date(2024, 3, 1),
1529 "Assets:Cash",
1530 Amount::new(dec!(0), "EUR"),
1531 )),
1532 ];
1533 let errors = validate(&directives);
1534 assert!(
1535 !errors
1536 .iter()
1537 .any(|e| e.code == ErrorCode::CurrencyNotAllowed),
1538 "unconstrained account must not flag balance currency (#1668); got: {:?}",
1539 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1540 );
1541 }
1542
1543 #[test]
1544 fn test_validate_future_date_warning() {
1545 let today = date(2024, 1, 1);
1549 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1550
1551 let directives = vec![Directive::Open(Open {
1552 date: future_date,
1553 account: "Assets:Bank".into(),
1554 currencies: vec![],
1555 booking: None,
1556 meta: Default::default(),
1557 })];
1558
1559 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1561 assert!(
1562 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1563 "Should not warn about future dates by default"
1564 );
1565
1566 let options = ValidationOptions::default().with_warn_future_dates(true);
1568 let errors = validate_with_today(&directives, options, today);
1569 assert!(
1570 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1571 "Should warn about future dates when enabled"
1572 );
1573 }
1574
1575 #[test]
1582 fn test_validate_with_today_threads_today_parameter() {
1583 let directives = vec![Directive::Open(Open {
1584 date: date(2024, 6, 15),
1585 account: "Assets:Bank".into(),
1586 currencies: vec![],
1587 booking: None,
1588 meta: Default::default(),
1589 })];
1590 let options = ValidationOptions::default().with_warn_future_dates(true);
1591
1592 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1594 assert!(
1595 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1596 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1597 );
1598
1599 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1601 assert!(
1602 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1603 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1604 );
1605 }
1606
1607 #[test]
1608 fn test_validate_document_not_found() {
1609 let directives = vec![
1610 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1611 Directive::Document(Document {
1612 date: date(2024, 1, 15),
1613 account: "Assets:Bank".into(),
1614 path: "/nonexistent/path/to/document.pdf".to_string(),
1615 tags: vec![],
1616 links: vec![],
1617 meta: Default::default(),
1618 }),
1619 ];
1620
1621 let errors = validate(&directives);
1623 assert!(
1624 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1625 "Should check documents by default"
1626 );
1627
1628 let options = ValidationOptions::default().with_check_documents(false);
1630 let errors = validate_with_options(&directives, options);
1631 assert!(
1632 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1633 "Should not report missing document when disabled"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_validate_document_account_not_open() {
1639 let directives = vec![Directive::Document(Document {
1640 date: date(2024, 1, 15),
1641 account: "Assets:Unknown".into(),
1642 path: "receipt.pdf".to_string(),
1643 tags: vec![],
1644 links: vec![],
1645 meta: Default::default(),
1646 })];
1647
1648 let errors = validate(&directives);
1649 assert!(
1650 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1651 "Should error for document on unopened account"
1652 );
1653 }
1654
1655 #[test]
1656 fn test_validate_document_relative_path_in_document_dirs() {
1657 let filename = "rustledger_test_889_relative_receipt.pdf";
1661 let dir = tempfile::tempdir().unwrap();
1662 let doc_subdir = dir.path().join("documents");
1663 std::fs::create_dir_all(&doc_subdir).unwrap();
1664 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1665
1666 let directives = vec![
1667 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1668 Directive::Document(Document {
1669 date: date(2024, 1, 15),
1670 account: "Assets:Bank".into(),
1671 path: filename.to_string(),
1672 tags: vec![],
1673 links: vec![],
1674 meta: Default::default(),
1675 }),
1676 ];
1677
1678 let errors = validate(&directives);
1680 assert!(
1681 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1682 "Should error when document_dirs not set"
1683 );
1684
1685 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1687 let errors = validate_with_options(&directives, options);
1688 assert!(
1689 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1690 "Should find document in document_dirs: {errors:?}"
1691 );
1692 }
1693
1694 #[test]
1695 fn test_validate_document_relative_path_not_found_in_dirs() {
1696 let filename = "rustledger_test_889_nonexistent.pdf";
1698 let dir = tempfile::tempdir().unwrap();
1699 let doc_subdir = dir.path().join("documents");
1700 std::fs::create_dir_all(&doc_subdir).unwrap();
1701
1702 let directives = vec![
1703 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1704 Directive::Document(Document {
1705 date: date(2024, 1, 15),
1706 account: "Assets:Bank".into(),
1707 path: filename.to_string(),
1708 tags: vec![],
1709 links: vec![],
1710 meta: Default::default(),
1711 }),
1712 ];
1713
1714 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1715 let errors = validate_with_options(&directives, options);
1716 assert!(
1717 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1718 "Should error when file not found in any document_dir"
1719 );
1720 }
1721
1722 #[test]
1723 fn test_validate_document_absolute_path_ignores_document_dirs() {
1724 let filename = "rustledger_test_889_absolute_receipt.pdf";
1725 let dir = tempfile::tempdir().unwrap();
1726 let doc_subdir = dir.path().join("documents");
1727 std::fs::create_dir_all(&doc_subdir).unwrap();
1728 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1729
1730 let directives = vec![
1731 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1732 Directive::Document(Document {
1733 date: date(2024, 1, 15),
1734 account: "Assets:Bank".into(),
1735 path: doc_subdir.join(filename).display().to_string(),
1736 tags: vec![],
1737 links: vec![],
1738 meta: Default::default(),
1739 }),
1740 ];
1741
1742 let options = ValidationOptions::default()
1744 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1745 let errors = validate_with_options(&directives, options);
1746 assert!(
1747 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1748 "Absolute path should work even with wrong document_dirs: {errors:?}"
1749 );
1750 }
1751
1752 #[test]
1761 fn test_validate_document_parallel_batch_check() {
1762 let dir = tempfile::tempdir().unwrap();
1763 let doc_subdir = dir.path().join("docs");
1764 std::fs::create_dir_all(&doc_subdir).unwrap();
1765
1766 let mut directives: Vec<Directive> =
1769 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1770 for i in 0..100 {
1771 let filename = format!("receipt_{i}.pdf");
1772 if i % 2 == 0 {
1773 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1774 }
1775 directives.push(Directive::Document(Document {
1776 date: date(2024, 1, 15),
1777 account: "Assets:Bank".into(),
1778 path: filename,
1779 tags: vec![],
1780 links: vec![],
1781 meta: Default::default(),
1782 }));
1783 }
1784
1785 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1786 let errors = validate_with_options(&directives, options);
1787
1788 let not_found_count = errors
1789 .iter()
1790 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1791 .count();
1792 assert_eq!(
1793 not_found_count, 50,
1794 "exactly 50 of 100 documents should error as not-found"
1795 );
1796
1797 let example = errors
1801 .iter()
1802 .find(|e| e.code == ErrorCode::DocumentNotFound)
1803 .expect("should have at least one not-found error");
1804 assert!(
1805 example
1806 .context
1807 .as_deref()
1808 .is_some_and(|c| c.contains("searched")),
1809 "error context should mention the searched dirs, got: {:?}",
1810 example.context
1811 );
1812 }
1813
1814 #[test]
1815 fn test_error_code_is_warning() {
1816 assert!(!ErrorCode::AccountNotOpen.is_warning());
1817 assert!(!ErrorCode::DocumentNotFound.is_warning());
1818 assert!(ErrorCode::FutureDate.is_warning());
1819 }
1820
1821 #[test]
1822 fn test_validate_pad_basic() {
1823 let directives = vec![
1824 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1825 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1826 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1827 Directive::Balance(Balance::new(
1828 date(2024, 1, 2),
1829 "Assets:Bank",
1830 Amount::new(dec!(1000.00), "USD"),
1831 )),
1832 ];
1833
1834 let errors = validate(&directives);
1835 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1837 }
1838
1839 #[test]
1840 fn test_validate_pad_with_existing_balance() {
1841 let directives = vec![
1842 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1843 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1844 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1845 Directive::Transaction(
1847 Transaction::new(date(2024, 1, 5), "Initial deposit")
1848 .with_synthesized_posting(Posting::new(
1849 "Assets:Bank",
1850 Amount::new(dec!(500.00), "USD"),
1851 ))
1852 .with_synthesized_posting(Posting::new(
1853 "Income:Salary",
1854 Amount::new(dec!(-500.00), "USD"),
1855 )),
1856 ),
1857 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1859 Directive::Balance(Balance::new(
1860 date(2024, 1, 15),
1861 "Assets:Bank",
1862 Amount::new(dec!(1000.00), "USD"), )),
1864 ];
1865
1866 let errors = validate(&directives);
1867 assert!(
1869 errors.is_empty(),
1870 "Pad should add missing amount: {errors:?}"
1871 );
1872 }
1873
1874 #[test]
1875 fn test_validate_pad_account_not_open() {
1876 let directives = vec![
1877 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1878 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1880 ];
1881
1882 let errors = validate(&directives);
1883 assert!(
1884 errors
1885 .iter()
1886 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1887 "Should error for pad on unopened account"
1888 );
1889 }
1890
1891 #[test]
1892 fn test_validate_pad_source_not_open() {
1893 let directives = vec![
1894 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1895 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1897 ];
1898
1899 let errors = validate(&directives);
1900 assert!(
1901 errors.iter().any(
1902 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1903 ),
1904 "Should error for pad with unopened source account"
1905 );
1906 }
1907
1908 #[test]
1909 fn test_validate_pad_negative_adjustment() {
1910 let directives = vec![
1912 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1913 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1914 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1915 Directive::Transaction(
1917 Transaction::new(date(2024, 1, 5), "Big deposit")
1918 .with_synthesized_posting(Posting::new(
1919 "Assets:Bank",
1920 Amount::new(dec!(2000.00), "USD"),
1921 ))
1922 .with_synthesized_posting(Posting::new(
1923 "Income:Salary",
1924 Amount::new(dec!(-2000.00), "USD"),
1925 )),
1926 ),
1927 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1929 Directive::Balance(Balance::new(
1930 date(2024, 1, 15),
1931 "Assets:Bank",
1932 Amount::new(dec!(1000.00), "USD"), )),
1934 ];
1935
1936 let errors = validate(&directives);
1937 assert!(
1938 errors.is_empty(),
1939 "Pad should handle negative adjustment: {errors:?}"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_validate_insufficient_units() {
1945 use rustledger_core::CostSpec;
1946
1947 let cost_spec = CostSpec::empty()
1948 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1949 .with_currency("USD");
1950
1951 let directives = vec![
1952 Directive::Open(
1953 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1954 ),
1955 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1956 Directive::Transaction(
1958 Transaction::new(date(2024, 1, 15), "Buy")
1959 .with_synthesized_posting(
1960 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1961 .with_cost(cost_spec.clone()),
1962 )
1963 .with_synthesized_posting(Posting::new(
1964 "Assets:Cash",
1965 Amount::new(dec!(-1500), "USD"),
1966 )),
1967 ),
1968 Directive::Transaction(
1970 Transaction::new(date(2024, 6, 1), "Sell too many")
1971 .with_synthesized_posting(
1972 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1973 .with_cost(cost_spec),
1974 )
1975 .with_synthesized_posting(Posting::new(
1976 "Assets:Cash",
1977 Amount::new(dec!(2250), "USD"),
1978 )),
1979 ),
1980 ];
1981
1982 let errors = validate(&directives);
1983 assert!(
1984 errors
1985 .iter()
1986 .any(|e| e.code == ErrorCode::InsufficientUnits),
1987 "Should error for insufficient units: {errors:?}"
1988 );
1989 }
1990
1991 #[test]
1992 fn test_validate_no_matching_lot() {
1993 use rustledger_core::CostSpec;
1994
1995 let directives = vec![
1996 Directive::Open(
1997 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1998 ),
1999 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2000 Directive::Transaction(
2002 Transaction::new(date(2024, 1, 15), "Buy")
2003 .with_synthesized_posting(
2004 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
2005 CostSpec::empty()
2006 .with_number(rustledger_core::CostNumber::PerUnit {
2007 value: dec!(150),
2008 })
2009 .with_currency("USD"),
2010 ),
2011 )
2012 .with_synthesized_posting(Posting::new(
2013 "Assets:Cash",
2014 Amount::new(dec!(-1500), "USD"),
2015 )),
2016 ),
2017 Directive::Transaction(
2019 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
2020 .with_synthesized_posting(
2021 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
2022 CostSpec::empty()
2023 .with_number(rustledger_core::CostNumber::PerUnit {
2024 value: dec!(160),
2025 })
2026 .with_currency("USD"),
2027 ),
2028 )
2029 .with_synthesized_posting(Posting::new(
2030 "Assets:Cash",
2031 Amount::new(dec!(800), "USD"),
2032 )),
2033 ),
2034 ];
2035
2036 let errors = validate(&directives);
2037 assert!(
2038 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
2039 "Should error for no matching lot: {errors:?}"
2040 );
2041 }
2042
2043 #[test]
2044 fn test_validate_multiple_lot_match_uses_fifo() {
2045 use rustledger_core::CostSpec;
2048
2049 let cost_spec = CostSpec::empty()
2050 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2051 .with_currency("USD");
2052
2053 let directives = vec![
2054 Directive::Open(
2055 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2056 ),
2057 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2058 Directive::Transaction(
2060 Transaction::new(date(2024, 1, 15), "Buy lot 1")
2061 .with_synthesized_posting(
2062 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2063 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
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, 2, 15), "Buy lot 2")
2073 .with_synthesized_posting(
2074 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2075 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
2076 )
2077 .with_synthesized_posting(Posting::new(
2078 "Assets:Cash",
2079 Amount::new(dec!(-1500), "USD"),
2080 )),
2081 ),
2082 Directive::Transaction(
2084 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
2085 .with_synthesized_posting(
2086 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2087 .with_cost(cost_spec),
2088 )
2089 .with_synthesized_posting(Posting::new(
2090 "Assets:Cash",
2091 Amount::new(dec!(750), "USD"),
2092 )),
2093 ),
2094 ];
2095
2096 let errors = validate(&directives);
2097 let booking_errors: Vec<_> = errors
2099 .iter()
2100 .filter(|e| {
2101 matches!(
2102 e.code,
2103 ErrorCode::InsufficientUnits
2104 | ErrorCode::NoMatchingLot
2105 | ErrorCode::AmbiguousLotMatch
2106 )
2107 })
2108 .collect();
2109 assert!(
2110 booking_errors.is_empty(),
2111 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
2112 );
2113 }
2114
2115 #[test]
2116 fn test_validate_successful_booking() {
2117 use rustledger_core::CostSpec;
2118
2119 let cost_spec = CostSpec::empty()
2120 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2121 .with_currency("USD");
2122
2123 let directives = vec![
2124 Directive::Open(
2125 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2126 ),
2127 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2128 Directive::Transaction(
2130 Transaction::new(date(2024, 1, 15), "Buy")
2131 .with_synthesized_posting(
2132 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2133 .with_cost(cost_spec.clone()),
2134 )
2135 .with_synthesized_posting(Posting::new(
2136 "Assets:Cash",
2137 Amount::new(dec!(-1500), "USD"),
2138 )),
2139 ),
2140 Directive::Transaction(
2142 Transaction::new(date(2024, 6, 1), "Sell")
2143 .with_synthesized_posting(
2144 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2145 .with_cost(cost_spec),
2146 )
2147 .with_synthesized_posting(Posting::new(
2148 "Assets:Cash",
2149 Amount::new(dec!(750), "USD"),
2150 )),
2151 ),
2152 ];
2153
2154 let errors = validate(&directives);
2155 let booking_errors: Vec<_> = errors
2157 .iter()
2158 .filter(|e| {
2159 matches!(
2160 e.code,
2161 ErrorCode::InsufficientUnits
2162 | ErrorCode::NoMatchingLot
2163 | ErrorCode::AmbiguousLotMatch
2164 )
2165 })
2166 .collect();
2167 assert!(
2168 booking_errors.is_empty(),
2169 "Should have no booking errors: {booking_errors:?}"
2170 );
2171 }
2172
2173 #[test]
2174 fn test_validate_account_already_open() {
2175 let directives = vec![
2176 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2177 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2179
2180 let errors = validate(&directives);
2181 assert!(
2182 errors
2183 .iter()
2184 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2185 "Should error for duplicate open: {errors:?}"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_validate_account_close_not_empty() {
2191 let directives = vec![
2192 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2193 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2194 Directive::Transaction(
2195 Transaction::new(date(2024, 1, 15), "Deposit")
2196 .with_synthesized_posting(Posting::new(
2197 "Assets:Bank",
2198 Amount::new(dec!(100.00), "USD"),
2199 ))
2200 .with_synthesized_posting(Posting::new(
2201 "Income:Salary",
2202 Amount::new(dec!(-100.00), "USD"),
2203 )),
2204 ),
2205 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2207
2208 let errors = validate(&directives);
2209 assert!(
2210 errors
2211 .iter()
2212 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2213 "Should warn for closing account with balance: {errors:?}"
2214 );
2215 }
2216
2217 #[test]
2218 fn test_validate_no_postings_allowed() {
2219 let directives = vec![
2222 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2223 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2224 ];
2225
2226 let errors = validate(&directives);
2227 assert!(
2228 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2229 "Should NOT error for transaction with no postings: {errors:?}"
2230 );
2231 }
2232
2233 #[test]
2234 fn test_validate_single_posting() {
2235 let directives = vec![
2236 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2237 Directive::Transaction(
2238 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2239 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2240 ),
2241 ),
2242 ];
2243
2244 let errors = validate(&directives);
2245 assert!(
2246 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2247 "Should warn for transaction with single posting: {errors:?}"
2248 );
2249 assert!(ErrorCode::SinglePosting.is_warning());
2251 }
2252
2253 #[test]
2254 fn test_validate_single_posting_zero_cost_no_warning() {
2255 let directives = vec![
2259 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2260 Directive::Transaction(
2261 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2262 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2263 rustledger_core::CostSpec::empty()
2264 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2265 .with_currency("USD"),
2266 ),
2267 ),
2268 ),
2269 ];
2270
2271 let errors = validate(&directives);
2272 assert!(
2273 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2274 "Should NOT warn for zero-cost single posting: {errors:?}"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_validate_single_posting_nonzero_cost_still_warns() {
2280 let directives = vec![
2282 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2283 Directive::Transaction(
2284 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2285 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2286 rustledger_core::CostSpec::empty()
2287 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2288 .with_currency("USD"),
2289 ),
2290 ),
2291 ),
2292 ];
2293
2294 let errors = validate(&directives);
2295 assert!(
2296 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2297 "Should warn for single posting with non-zero cost: {errors:?}"
2298 );
2299 }
2300
2301 #[test]
2302 fn test_validate_pad_without_balance() {
2303 let directives = vec![
2304 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2305 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2306 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2307 ];
2309
2310 let errors = validate(&directives);
2311 assert!(
2312 errors
2313 .iter()
2314 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2315 "Should error for pad without subsequent balance: {errors:?}"
2316 );
2317 }
2318
2319 #[test]
2320 fn test_validate_multiple_pads_for_balance() {
2321 let directives = vec![
2322 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2323 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2324 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2325 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2327 date(2024, 1, 3),
2328 "Assets:Bank",
2329 Amount::new(dec!(1000.00), "USD"),
2330 )),
2331 ];
2332
2333 let errors = validate(&directives);
2334 assert!(
2335 errors
2336 .iter()
2337 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2338 "Should error for multiple pads before balance: {errors:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2344 let directives = vec![
2350 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2351 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2352 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2354 Directive::Balance(Balance::new(
2355 date(2024, 1, 2),
2356 "Assets:Bank",
2357 Amount::new(dec!(100.00), "USD"),
2358 )),
2359 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2362 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2363 Directive::Balance(Balance::new(
2364 date(2024, 2, 3),
2365 "Assets:Bank",
2366 Amount::new(dec!(200.00), "USD"),
2367 )),
2368 ];
2369
2370 let errors = validate(&directives);
2371 let multi_pad_count = errors
2372 .iter()
2373 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2374 .count();
2375 assert_eq!(
2376 multi_pad_count, 1,
2377 "E2004 must fire exactly once on the second balance; got {errors:?}"
2378 );
2379 }
2380
2381 #[test]
2382 fn test_pad_serves_multi_currency_balances_on_same_day() {
2383 let directives = vec![
2390 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2391 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2392 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2393 Directive::Balance(Balance::new(
2395 date(2024, 1, 2),
2396 "Assets:Bank",
2397 Amount::new(dec!(100.00), "USD"),
2398 )),
2399 Directive::Balance(Balance::new(
2400 date(2024, 1, 2),
2401 "Assets:Bank",
2402 Amount::new(dec!(50.00), "EUR"),
2403 )),
2404 ];
2405
2406 let errors = validate(&directives);
2407 assert!(
2408 !errors
2409 .iter()
2410 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2411 "pad should serve both USD and EUR; got {errors:?}"
2412 );
2413 assert!(
2414 !errors
2415 .iter()
2416 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2417 "pad serves at least one balance; should not be E2003; got {errors:?}"
2418 );
2419 }
2420
2421 #[test]
2422 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2423 let directives = vec![
2428 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2429 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2430 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2431 Directive::Balance(Balance::new(
2432 date(2024, 1, 2),
2433 "Assets:Bank",
2434 Amount::new(dec!(100.00), "USD"),
2435 )),
2436 ];
2437
2438 let errors = validate(&directives);
2439 assert!(
2443 errors
2444 .iter()
2445 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2446 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2447 );
2448 assert!(
2450 errors
2451 .iter()
2452 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2453 "same-day pad never consumed; expected E2003; got {errors:?}"
2454 );
2455 }
2456
2457 #[test]
2458 fn test_future_pad_does_not_apply_to_earlier_balance() {
2459 let directives = vec![
2465 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2466 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2467 Directive::Balance(Balance::new(
2468 date(2024, 1, 2),
2469 "Assets:Bank",
2470 Amount::new(dec!(0.00), "USD"),
2471 )),
2472 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2473 ];
2474
2475 let errors = validate(&directives);
2476 assert!(
2479 !errors
2480 .iter()
2481 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2482 "future pad should not influence earlier balance; got {errors:?}"
2483 );
2484 assert!(
2486 errors
2487 .iter()
2488 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2489 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_error_severity() {
2495 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2497 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2498 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2499
2500 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2502 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2503 assert_eq!(
2504 ErrorCode::AccountCloseNotEmpty.severity(),
2505 Severity::Warning
2506 );
2507
2508 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2510 }
2511
2512 #[test]
2513 fn test_validate_invalid_account_name() {
2514 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2516
2517 let errors = validate(&directives);
2518 assert!(
2519 errors
2520 .iter()
2521 .any(|e| e.code == ErrorCode::InvalidAccountName),
2522 "Should error for invalid account root: {errors:?}"
2523 );
2524 }
2525
2526 #[test]
2527 fn test_validate_account_lowercase_component() {
2528 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2530
2531 let errors = validate(&directives);
2532 assert!(
2533 errors
2534 .iter()
2535 .any(|e| e.code == ErrorCode::InvalidAccountName),
2536 "Should error for lowercase component: {errors:?}"
2537 );
2538 }
2539
2540 #[test]
2541 fn test_validate_valid_account_names() {
2542 let valid_names = [
2544 "Assets:Bank",
2545 "Assets:Bank:Checking",
2546 "Liabilities:CreditCard",
2547 "Equity:Opening-Balances",
2548 "Income:Salary2024",
2549 "Expenses:Food:Restaurant",
2550 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2556
2557 for name in valid_names {
2558 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2559
2560 let errors = validate(&directives);
2561 let name_errors: Vec<_> = errors
2562 .iter()
2563 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2564 .collect();
2565 assert!(
2566 name_errors.is_empty(),
2567 "Should accept valid account name '{name}': {name_errors:?}"
2568 );
2569 }
2570 }
2571
2572 #[test]
2577 fn test_e2002_balance_exceeds_explicit_tolerance() {
2578 let directives = vec![
2581 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2582 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2583 Directive::Transaction(
2584 Transaction::new(date(2024, 1, 15), "Deposit")
2585 .with_synthesized_posting(Posting::new(
2586 "Assets:Bank",
2587 Amount::new(dec!(1000.00), "USD"),
2588 ))
2589 .with_synthesized_posting(Posting::new(
2590 "Income:Salary",
2591 Amount::new(dec!(-1000.00), "USD"),
2592 )),
2593 ),
2594 Directive::Balance(
2597 Balance::new(
2598 date(2024, 1, 16),
2599 "Assets:Bank",
2600 Amount::new(dec!(999.00), "USD"),
2601 )
2602 .with_tolerance(dec!(0.01)),
2603 ),
2604 ];
2605
2606 let errors = validate(&directives);
2607
2608 assert!(
2609 errors
2610 .iter()
2611 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2612 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2613 );
2614 }
2615
2616 #[test]
2617 fn test_e2002_balance_within_explicit_tolerance_passes() {
2618 let directives = vec![
2620 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2621 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2622 Directive::Transaction(
2623 Transaction::new(date(2024, 1, 15), "Deposit")
2624 .with_synthesized_posting(Posting::new(
2625 "Assets:Bank",
2626 Amount::new(dec!(1000.00), "USD"),
2627 ))
2628 .with_synthesized_posting(Posting::new(
2629 "Income:Salary",
2630 Amount::new(dec!(-1000.00), "USD"),
2631 )),
2632 ),
2633 Directive::Balance(
2635 Balance::new(
2636 date(2024, 1, 16),
2637 "Assets:Bank",
2638 Amount::new(dec!(999.00), "USD"),
2639 )
2640 .with_tolerance(dec!(5.00)),
2641 ),
2642 ];
2643
2644 let errors = validate(&directives);
2645
2646 assert!(
2647 !errors
2648 .iter()
2649 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2650 || e.code == ErrorCode::BalanceAssertionFailed),
2651 "Expected no balance errors, got: {errors:?}"
2652 );
2653 }
2654
2655 #[test]
2656 fn test_e5001_undeclared_currency() {
2657 use rustledger_core::Commodity;
2660
2661 let directives = vec![
2662 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2663 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2664 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2665 Directive::Transaction(
2666 Transaction::new(date(2024, 1, 15), "Lunch")
2667 .with_synthesized_posting(Posting::new(
2668 "Expenses:Food",
2669 Amount::new(dec!(20.00), "EUR"), ))
2671 .with_synthesized_posting(Posting::new(
2672 "Assets:Bank",
2673 Amount::new(dec!(-20.00), "EUR"),
2674 )),
2675 ),
2676 ];
2677
2678 let options = ValidationOptions::default().with_require_commodities(true);
2679 let errors = validate_with_options(&directives, options);
2680
2681 assert!(
2682 errors
2683 .iter()
2684 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2685 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2686 );
2687 }
2688
2689 #[test]
2690 fn test_e5001_declared_currency_passes() {
2691 use rustledger_core::Commodity;
2693
2694 let directives = vec![
2695 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2696 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2697 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2698 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2699 Directive::Transaction(
2700 Transaction::new(date(2024, 1, 15), "Lunch")
2701 .with_synthesized_posting(Posting::new(
2702 "Expenses:Food",
2703 Amount::new(dec!(20.00), "EUR"),
2704 ))
2705 .with_synthesized_posting(Posting::new(
2706 "Assets:Bank",
2707 Amount::new(dec!(-20.00), "EUR"),
2708 )),
2709 ),
2710 ];
2711
2712 let options = ValidationOptions::default().with_require_commodities(true);
2713 let errors = validate_with_options(&directives, options);
2714
2715 assert!(
2716 !errors
2717 .iter()
2718 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2719 "Expected no E5001 errors, got: {errors:?}"
2720 );
2721 }
2722
2723 #[test]
2724 fn test_e5001_not_raised_without_require_commodities() {
2725 let directives = vec![
2727 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2728 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2729 Directive::Transaction(
2730 Transaction::new(date(2024, 1, 15), "Lunch")
2731 .with_synthesized_posting(Posting::new(
2732 "Expenses:Food",
2733 Amount::new(dec!(20.00), "XYZ"), ))
2735 .with_synthesized_posting(Posting::new(
2736 "Assets:Bank",
2737 Amount::new(dec!(-20.00), "XYZ"),
2738 )),
2739 ),
2740 ];
2741
2742 let errors = validate(&directives);
2743
2744 assert!(
2745 !errors
2746 .iter()
2747 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2748 "Should not raise E5001 without require_commodities, got: {errors:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_e3002_multiple_missing_amounts() {
2754 let directives = vec![
2756 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2757 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2758 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2759 Directive::Transaction(
2760 Transaction::new(date(2024, 1, 15), "Lunch")
2761 .with_synthesized_posting(Posting::new(
2762 "Assets:Bank",
2763 Amount::new(dec!(-50.00), "USD"),
2764 ))
2765 .with_synthesized_posting(Posting {
2767 account: "Expenses:Food".into(),
2768 units: None,
2769 cost: None,
2770 price: None,
2771 flag: None,
2772 meta: Default::default(),
2773 comments: vec![],
2774 trailing_comments: vec![],
2775 })
2776 .with_synthesized_posting(Posting {
2777 account: "Expenses:Drinks".into(),
2778 units: None,
2779 cost: None,
2780 price: None,
2781 flag: None,
2782 meta: Default::default(),
2783 comments: vec![],
2784 trailing_comments: vec![],
2785 }),
2786 ),
2787 ];
2788
2789 let errors = validate(&directives);
2790
2791 assert!(
2792 errors
2793 .iter()
2794 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2795 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2796 );
2797 }
2798
2799 #[test]
2800 fn test_e3002_single_missing_amount_ok() {
2801 let directives = vec![
2803 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2804 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2805 Directive::Transaction(
2806 Transaction::new(date(2024, 1, 15), "Lunch")
2807 .with_synthesized_posting(Posting::new(
2808 "Assets:Bank",
2809 Amount::new(dec!(-50.00), "USD"),
2810 ))
2811 .with_synthesized_posting(Posting {
2812 account: "Expenses:Food".into(),
2813 units: None,
2814 cost: None,
2815 price: None,
2816 flag: None,
2817 meta: Default::default(),
2818 comments: vec![],
2819 trailing_comments: vec![],
2820 }),
2821 ),
2822 ];
2823
2824 let errors = validate(&directives);
2825
2826 assert!(
2827 !errors
2828 .iter()
2829 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2830 "Should not raise E3002 with single missing amount, got: {errors:?}"
2831 );
2832 }
2833
2834 #[test]
2835 fn test_e7001_unknown_option() {
2836 let state = LedgerState::new();
2838 let mut errors = Vec::new();
2839
2840 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2841
2842 assert_eq!(errors.len(), 1);
2843 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2844 assert!(errors[0].message.contains("bogus_option"));
2845 }
2846
2847 #[test]
2848 fn test_e7002_invalid_option_value() {
2849 let state = LedgerState::new();
2850 let mut errors = Vec::new();
2851
2852 state.import_option_warnings(
2853 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2854 &mut errors,
2855 );
2856
2857 assert_eq!(errors.len(), 1);
2858 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2859 }
2860
2861 #[test]
2862 fn test_e7003_duplicate_option() {
2863 let state = LedgerState::new();
2864 let mut errors = Vec::new();
2865
2866 state.import_option_warnings(
2867 &[("E7003", "Option \"title\" can only be specified once")],
2868 &mut errors,
2869 );
2870
2871 assert_eq!(errors.len(), 1);
2872 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2873 }
2874
2875 fn commodity_with_precision(value: MetaValue) -> Directive {
2878 let mut meta = rustledger_core::Metadata::default();
2879 meta.insert("precision".into(), value);
2880 Directive::Commodity(
2881 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2882 )
2883 }
2884
2885 #[test]
2886 fn precision_meta_valid_integer_emits_no_warning() {
2887 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2888 let errors = validate(&directives);
2889 assert!(
2890 errors
2891 .iter()
2892 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2893 "valid precision must not produce a warning, got: {errors:?}"
2894 );
2895 }
2896
2897 #[test]
2898 fn precision_meta_zero_is_valid() {
2899 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2900 let errors = validate(&directives);
2901 assert!(
2902 errors
2903 .iter()
2904 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2905 );
2906 }
2907
2908 #[test]
2909 fn precision_meta_negative_emits_e5003() {
2910 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2911 let errors = validate(&directives);
2912 let warnings: Vec<_> = errors
2913 .iter()
2914 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2915 .collect();
2916 assert_eq!(warnings.len(), 1, "expected one E5003");
2917 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2918 assert!(warnings[0].message.contains("non-negative"));
2919 }
2920
2921 #[test]
2922 fn precision_meta_non_integer_emits_e5003() {
2923 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2924 let errors = validate(&directives);
2925 let warnings: Vec<_> = errors
2926 .iter()
2927 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2928 .collect();
2929 assert_eq!(warnings.len(), 1);
2930 assert!(warnings[0].message.contains("integer"));
2931 }
2932
2933 #[test]
2934 fn precision_meta_string_value_emits_e5003() {
2935 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2936 let errors = validate(&directives);
2937 let warnings: Vec<_> = errors
2938 .iter()
2939 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2940 .collect();
2941 assert_eq!(warnings.len(), 1);
2942 assert!(warnings[0].message.contains("string"));
2943 }
2944
2945 #[test]
2946 fn precision_meta_out_of_u32_range_emits_e5003() {
2947 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2949 8589934592
2950 )))];
2951 let errors = validate(&directives);
2952 let warnings: Vec<_> = errors
2953 .iter()
2954 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2955 .collect();
2956 assert_eq!(warnings.len(), 1);
2957 assert!(warnings[0].message.contains("exceeds"));
2958 }
2959
2960 #[test]
2961 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2962 let directives = vec![
2967 commodity_with_precision(MetaValue::Number(dec!(2))),
2968 commodity_with_precision(MetaValue::Number(dec!(-1))),
2969 ];
2970 let warnings: Vec<_> = validate(&directives)
2971 .into_iter()
2972 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2973 .collect();
2974 assert_eq!(
2975 warnings.len(),
2976 1,
2977 "exactly one E5003 expected (only the invalid declaration)"
2978 );
2979 assert!(warnings[0].message.contains("non-negative"));
2980 }
2981
2982 #[test]
2983 fn precision_meta_e5003_is_warning_severity() {
2984 assert_eq!(
2988 ErrorCode::InvalidPrecisionMetadata.severity(),
2989 Severity::Warning
2990 );
2991 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2992 }
2993
2994 #[test]
3002 fn test_validate_early_emits_e1001_on_elided_posting() {
3003 let directives = vec![
3004 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3005 Directive::Transaction(
3006 Transaction::new(date(2024, 1, 15), "Zero to unopened")
3007 .with_synthesized_posting(Posting::new(
3008 "Assets:Bank",
3009 Amount::new(dec!(0.00), "USD"),
3010 ))
3011 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
3012 ),
3013 ];
3014
3015 let session = ValidationSession::new(ValidationOptions::default());
3016 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
3017
3018 assert!(
3019 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
3020 && e.to_string().contains("Expenses:NeverOpened")),
3021 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
3022 );
3023 }
3024
3025 #[test]
3029 fn test_validate_late_does_not_duplicate_e1001() {
3030 let directives = vec![
3031 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3032 Directive::Transaction(
3033 Transaction::new(date(2024, 1, 15), "To unopened")
3034 .with_synthesized_posting(Posting::new(
3035 "Assets:Bank",
3036 Amount::new(dec!(100), "USD"),
3037 ))
3038 .with_synthesized_posting(Posting::new(
3039 "Expenses:NeverOpened",
3040 Amount::new(dec!(-100), "USD"),
3041 )),
3042 ),
3043 ];
3044
3045 let session = ValidationSession::new(ValidationOptions::default());
3046 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
3047 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
3048
3049 let early_e1001 = early
3050 .iter()
3051 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3052 .count();
3053 let late_e1001 = late
3054 .iter()
3055 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3056 .count();
3057
3058 assert_eq!(
3059 early_e1001, 0,
3060 "explicit posting: early phase defers E1001 to late; got: {early:?}"
3061 );
3062 assert_eq!(
3063 late_e1001, 1,
3064 "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
3065 );
3066 }
3067
3068 #[test]
3074 fn test_validate_chained_matches_explicit_phases() {
3075 let directives = vec![
3079 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3080 Directive::Transaction(
3081 Transaction::new(date(2024, 1, 15), "Mixed")
3082 .with_synthesized_posting(Posting::new(
3083 "Assets:Bank",
3084 Amount::new(dec!(50), "USD"),
3085 ))
3086 .with_synthesized_posting(Posting::new(
3087 "Income:Salary",
3088 Amount::new(dec!(-50), "USD"),
3089 )),
3090 ),
3091 Directive::Balance(Balance::new(
3092 date(2024, 1, 16),
3093 "Assets:Bank",
3094 Amount::new(dec!(50), "USD"),
3095 )),
3096 ];
3097
3098 let chained = validate(&directives);
3100
3101 let session = ValidationSession::new(ValidationOptions::default());
3103 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3104 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3105 explicit.extend(late_errs);
3106 explicit.extend(session.finalize());
3107
3108 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3112 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3113 assert_eq!(
3114 chained_strs, explicit_strs,
3115 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3116 );
3117 }
3118
3119 #[test]
3120 fn test_phase_order_early_then_late_then_finalize() {
3121 let directives = vec![
3128 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3129 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3130 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3131 Directive::Transaction(
3133 Transaction::new(date(2024, 1, 5), "early")
3134 .with_synthesized_posting(Posting::new(
3135 "Assets:Bank",
3136 Amount::new(dec!(100), "USD"),
3137 ))
3138 .with_synthesized_posting(Posting::new(
3139 "Income:Salary",
3140 Amount::new(dec!(-100), "USD"),
3141 )),
3142 ),
3143 Directive::Pad(Pad::new(
3145 date(2024, 1, 10),
3146 "Assets:Other",
3147 "Equity:Opening",
3148 )),
3149 Directive::Balance(Balance::new(
3151 date(2024, 2, 1),
3152 "Assets:Bank",
3153 Amount::new(dec!(999), "USD"),
3154 )),
3155 ];
3156
3157 let errors = validate(&directives);
3158 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3159
3160 let early_pos = codes
3161 .iter()
3162 .position(|c| *c == ErrorCode::AccountNotOpen)
3163 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3164 let late_pos = codes
3165 .iter()
3166 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3167 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3168 let finalize_pos = codes
3169 .iter()
3170 .position(|c| *c == ErrorCode::PadWithoutBalance)
3171 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3172
3173 assert!(
3174 early_pos < late_pos,
3175 "early-phase errors must precede late-phase; got {codes:?}"
3176 );
3177 assert!(
3178 late_pos < finalize_pos,
3179 "late-phase errors must precede finalize; got {codes:?}"
3180 );
3181 }
3182
3183 #[test]
3184 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3185 let directives = vec![
3192 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3193 Directive::Transaction(
3196 Transaction::new(date(2024, 1, 10), "leave residue")
3197 .with_synthesized_posting(Posting::new(
3198 "Assets:Bank",
3199 Amount::new(dec!(50), "USD"),
3200 ))
3201 .with_synthesized_posting(Posting::new(
3202 "Equity:Opening",
3203 Amount::new(dec!(-50), "USD"),
3204 )),
3205 ),
3206 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3207 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3208 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3209 ];
3210
3211 let errors = validate(&directives);
3212 let close_not_empty_count = errors
3213 .iter()
3214 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3215 .count();
3216 assert_eq!(
3217 close_not_empty_count, 1,
3218 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3219 );
3220 let account_closed_count = errors
3222 .iter()
3223 .filter(|e| e.code == ErrorCode::AccountClosed)
3224 .count();
3225 assert_eq!(
3226 account_closed_count, 1,
3227 "duplicate close should still report AccountClosed once; got {errors:?}"
3228 );
3229 }
3230
3231 #[test]
3257 fn typestate_pins_phase_ordering_at_compile_time() {
3258 fn _expect_pending_returns_early(
3270 s: ValidationSession<Pending>,
3271 ) -> ValidationSession<EarlyDone> {
3272 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3273 s
3274 }
3275 fn _expect_early_returns_late(
3276 s: ValidationSession<EarlyDone>,
3277 ) -> ValidationSession<LateDone> {
3278 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3279 s
3280 }
3281 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3282 s.finalize()
3283 }
3284 }
3285}