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_future_date_warning() {
1498 let today = date(2024, 1, 1);
1502 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1503
1504 let directives = vec![Directive::Open(Open {
1505 date: future_date,
1506 account: "Assets:Bank".into(),
1507 currencies: vec![],
1508 booking: None,
1509 meta: Default::default(),
1510 })];
1511
1512 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1514 assert!(
1515 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1516 "Should not warn about future dates by default"
1517 );
1518
1519 let options = ValidationOptions::default().with_warn_future_dates(true);
1521 let errors = validate_with_today(&directives, options, today);
1522 assert!(
1523 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1524 "Should warn about future dates when enabled"
1525 );
1526 }
1527
1528 #[test]
1535 fn test_validate_with_today_threads_today_parameter() {
1536 let directives = vec![Directive::Open(Open {
1537 date: date(2024, 6, 15),
1538 account: "Assets:Bank".into(),
1539 currencies: vec![],
1540 booking: None,
1541 meta: Default::default(),
1542 })];
1543 let options = ValidationOptions::default().with_warn_future_dates(true);
1544
1545 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1547 assert!(
1548 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1549 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1550 );
1551
1552 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1554 assert!(
1555 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1556 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_validate_document_not_found() {
1562 let directives = vec![
1563 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1564 Directive::Document(Document {
1565 date: date(2024, 1, 15),
1566 account: "Assets:Bank".into(),
1567 path: "/nonexistent/path/to/document.pdf".to_string(),
1568 tags: vec![],
1569 links: vec![],
1570 meta: Default::default(),
1571 }),
1572 ];
1573
1574 let errors = validate(&directives);
1576 assert!(
1577 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1578 "Should check documents by default"
1579 );
1580
1581 let options = ValidationOptions::default().with_check_documents(false);
1583 let errors = validate_with_options(&directives, options);
1584 assert!(
1585 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1586 "Should not report missing document when disabled"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_validate_document_account_not_open() {
1592 let directives = vec![Directive::Document(Document {
1593 date: date(2024, 1, 15),
1594 account: "Assets:Unknown".into(),
1595 path: "receipt.pdf".to_string(),
1596 tags: vec![],
1597 links: vec![],
1598 meta: Default::default(),
1599 })];
1600
1601 let errors = validate(&directives);
1602 assert!(
1603 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1604 "Should error for document on unopened account"
1605 );
1606 }
1607
1608 #[test]
1609 fn test_validate_document_relative_path_in_document_dirs() {
1610 let filename = "rustledger_test_889_relative_receipt.pdf";
1614 let dir = tempfile::tempdir().unwrap();
1615 let doc_subdir = dir.path().join("documents");
1616 std::fs::create_dir_all(&doc_subdir).unwrap();
1617 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1618
1619 let directives = vec![
1620 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1621 Directive::Document(Document {
1622 date: date(2024, 1, 15),
1623 account: "Assets:Bank".into(),
1624 path: filename.to_string(),
1625 tags: vec![],
1626 links: vec![],
1627 meta: Default::default(),
1628 }),
1629 ];
1630
1631 let errors = validate(&directives);
1633 assert!(
1634 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1635 "Should error when document_dirs not set"
1636 );
1637
1638 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1640 let errors = validate_with_options(&directives, options);
1641 assert!(
1642 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1643 "Should find document in document_dirs: {errors:?}"
1644 );
1645 }
1646
1647 #[test]
1648 fn test_validate_document_relative_path_not_found_in_dirs() {
1649 let filename = "rustledger_test_889_nonexistent.pdf";
1651 let dir = tempfile::tempdir().unwrap();
1652 let doc_subdir = dir.path().join("documents");
1653 std::fs::create_dir_all(&doc_subdir).unwrap();
1654
1655 let directives = vec![
1656 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1657 Directive::Document(Document {
1658 date: date(2024, 1, 15),
1659 account: "Assets:Bank".into(),
1660 path: filename.to_string(),
1661 tags: vec![],
1662 links: vec![],
1663 meta: Default::default(),
1664 }),
1665 ];
1666
1667 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1668 let errors = validate_with_options(&directives, options);
1669 assert!(
1670 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1671 "Should error when file not found in any document_dir"
1672 );
1673 }
1674
1675 #[test]
1676 fn test_validate_document_absolute_path_ignores_document_dirs() {
1677 let filename = "rustledger_test_889_absolute_receipt.pdf";
1678 let dir = tempfile::tempdir().unwrap();
1679 let doc_subdir = dir.path().join("documents");
1680 std::fs::create_dir_all(&doc_subdir).unwrap();
1681 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1682
1683 let directives = vec![
1684 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1685 Directive::Document(Document {
1686 date: date(2024, 1, 15),
1687 account: "Assets:Bank".into(),
1688 path: doc_subdir.join(filename).display().to_string(),
1689 tags: vec![],
1690 links: vec![],
1691 meta: Default::default(),
1692 }),
1693 ];
1694
1695 let options = ValidationOptions::default()
1697 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1698 let errors = validate_with_options(&directives, options);
1699 assert!(
1700 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1701 "Absolute path should work even with wrong document_dirs: {errors:?}"
1702 );
1703 }
1704
1705 #[test]
1714 fn test_validate_document_parallel_batch_check() {
1715 let dir = tempfile::tempdir().unwrap();
1716 let doc_subdir = dir.path().join("docs");
1717 std::fs::create_dir_all(&doc_subdir).unwrap();
1718
1719 let mut directives: Vec<Directive> =
1722 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1723 for i in 0..100 {
1724 let filename = format!("receipt_{i}.pdf");
1725 if i % 2 == 0 {
1726 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1727 }
1728 directives.push(Directive::Document(Document {
1729 date: date(2024, 1, 15),
1730 account: "Assets:Bank".into(),
1731 path: filename,
1732 tags: vec![],
1733 links: vec![],
1734 meta: Default::default(),
1735 }));
1736 }
1737
1738 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1739 let errors = validate_with_options(&directives, options);
1740
1741 let not_found_count = errors
1742 .iter()
1743 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1744 .count();
1745 assert_eq!(
1746 not_found_count, 50,
1747 "exactly 50 of 100 documents should error as not-found"
1748 );
1749
1750 let example = errors
1754 .iter()
1755 .find(|e| e.code == ErrorCode::DocumentNotFound)
1756 .expect("should have at least one not-found error");
1757 assert!(
1758 example
1759 .context
1760 .as_deref()
1761 .is_some_and(|c| c.contains("searched")),
1762 "error context should mention the searched dirs, got: {:?}",
1763 example.context
1764 );
1765 }
1766
1767 #[test]
1768 fn test_error_code_is_warning() {
1769 assert!(!ErrorCode::AccountNotOpen.is_warning());
1770 assert!(!ErrorCode::DocumentNotFound.is_warning());
1771 assert!(ErrorCode::FutureDate.is_warning());
1772 }
1773
1774 #[test]
1775 fn test_validate_pad_basic() {
1776 let directives = vec![
1777 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1778 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1779 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1780 Directive::Balance(Balance::new(
1781 date(2024, 1, 2),
1782 "Assets:Bank",
1783 Amount::new(dec!(1000.00), "USD"),
1784 )),
1785 ];
1786
1787 let errors = validate(&directives);
1788 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1790 }
1791
1792 #[test]
1793 fn test_validate_pad_with_existing_balance() {
1794 let directives = vec![
1795 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1796 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1797 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1798 Directive::Transaction(
1800 Transaction::new(date(2024, 1, 5), "Initial deposit")
1801 .with_synthesized_posting(Posting::new(
1802 "Assets:Bank",
1803 Amount::new(dec!(500.00), "USD"),
1804 ))
1805 .with_synthesized_posting(Posting::new(
1806 "Income:Salary",
1807 Amount::new(dec!(-500.00), "USD"),
1808 )),
1809 ),
1810 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1812 Directive::Balance(Balance::new(
1813 date(2024, 1, 15),
1814 "Assets:Bank",
1815 Amount::new(dec!(1000.00), "USD"), )),
1817 ];
1818
1819 let errors = validate(&directives);
1820 assert!(
1822 errors.is_empty(),
1823 "Pad should add missing amount: {errors:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_validate_pad_account_not_open() {
1829 let directives = vec![
1830 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1831 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1833 ];
1834
1835 let errors = validate(&directives);
1836 assert!(
1837 errors
1838 .iter()
1839 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1840 "Should error for pad on unopened account"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_validate_pad_source_not_open() {
1846 let directives = vec![
1847 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1848 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1850 ];
1851
1852 let errors = validate(&directives);
1853 assert!(
1854 errors.iter().any(
1855 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1856 ),
1857 "Should error for pad with unopened source account"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_validate_pad_negative_adjustment() {
1863 let directives = vec![
1865 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1866 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1867 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1868 Directive::Transaction(
1870 Transaction::new(date(2024, 1, 5), "Big deposit")
1871 .with_synthesized_posting(Posting::new(
1872 "Assets:Bank",
1873 Amount::new(dec!(2000.00), "USD"),
1874 ))
1875 .with_synthesized_posting(Posting::new(
1876 "Income:Salary",
1877 Amount::new(dec!(-2000.00), "USD"),
1878 )),
1879 ),
1880 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1882 Directive::Balance(Balance::new(
1883 date(2024, 1, 15),
1884 "Assets:Bank",
1885 Amount::new(dec!(1000.00), "USD"), )),
1887 ];
1888
1889 let errors = validate(&directives);
1890 assert!(
1891 errors.is_empty(),
1892 "Pad should handle negative adjustment: {errors:?}"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_validate_insufficient_units() {
1898 use rustledger_core::CostSpec;
1899
1900 let cost_spec = CostSpec::empty()
1901 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1902 .with_currency("USD");
1903
1904 let directives = vec![
1905 Directive::Open(
1906 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1907 ),
1908 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1909 Directive::Transaction(
1911 Transaction::new(date(2024, 1, 15), "Buy")
1912 .with_synthesized_posting(
1913 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1914 .with_cost(cost_spec.clone()),
1915 )
1916 .with_synthesized_posting(Posting::new(
1917 "Assets:Cash",
1918 Amount::new(dec!(-1500), "USD"),
1919 )),
1920 ),
1921 Directive::Transaction(
1923 Transaction::new(date(2024, 6, 1), "Sell too many")
1924 .with_synthesized_posting(
1925 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1926 .with_cost(cost_spec),
1927 )
1928 .with_synthesized_posting(Posting::new(
1929 "Assets:Cash",
1930 Amount::new(dec!(2250), "USD"),
1931 )),
1932 ),
1933 ];
1934
1935 let errors = validate(&directives);
1936 assert!(
1937 errors
1938 .iter()
1939 .any(|e| e.code == ErrorCode::InsufficientUnits),
1940 "Should error for insufficient units: {errors:?}"
1941 );
1942 }
1943
1944 #[test]
1945 fn test_validate_no_matching_lot() {
1946 use rustledger_core::CostSpec;
1947
1948 let directives = vec![
1949 Directive::Open(
1950 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1951 ),
1952 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1953 Directive::Transaction(
1955 Transaction::new(date(2024, 1, 15), "Buy")
1956 .with_synthesized_posting(
1957 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1958 CostSpec::empty()
1959 .with_number(rustledger_core::CostNumber::PerUnit {
1960 value: dec!(150),
1961 })
1962 .with_currency("USD"),
1963 ),
1964 )
1965 .with_synthesized_posting(Posting::new(
1966 "Assets:Cash",
1967 Amount::new(dec!(-1500), "USD"),
1968 )),
1969 ),
1970 Directive::Transaction(
1972 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1973 .with_synthesized_posting(
1974 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1975 CostSpec::empty()
1976 .with_number(rustledger_core::CostNumber::PerUnit {
1977 value: dec!(160),
1978 })
1979 .with_currency("USD"),
1980 ),
1981 )
1982 .with_synthesized_posting(Posting::new(
1983 "Assets:Cash",
1984 Amount::new(dec!(800), "USD"),
1985 )),
1986 ),
1987 ];
1988
1989 let errors = validate(&directives);
1990 assert!(
1991 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1992 "Should error for no matching lot: {errors:?}"
1993 );
1994 }
1995
1996 #[test]
1997 fn test_validate_multiple_lot_match_uses_fifo() {
1998 use rustledger_core::CostSpec;
2001
2002 let cost_spec = CostSpec::empty()
2003 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2004 .with_currency("USD");
2005
2006 let directives = vec![
2007 Directive::Open(
2008 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2009 ),
2010 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2011 Directive::Transaction(
2013 Transaction::new(date(2024, 1, 15), "Buy lot 1")
2014 .with_synthesized_posting(
2015 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2016 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
2017 )
2018 .with_synthesized_posting(Posting::new(
2019 "Assets:Cash",
2020 Amount::new(dec!(-1500), "USD"),
2021 )),
2022 ),
2023 Directive::Transaction(
2025 Transaction::new(date(2024, 2, 15), "Buy lot 2")
2026 .with_synthesized_posting(
2027 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2028 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
2029 )
2030 .with_synthesized_posting(Posting::new(
2031 "Assets:Cash",
2032 Amount::new(dec!(-1500), "USD"),
2033 )),
2034 ),
2035 Directive::Transaction(
2037 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
2038 .with_synthesized_posting(
2039 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2040 .with_cost(cost_spec),
2041 )
2042 .with_synthesized_posting(Posting::new(
2043 "Assets:Cash",
2044 Amount::new(dec!(750), "USD"),
2045 )),
2046 ),
2047 ];
2048
2049 let errors = validate(&directives);
2050 let booking_errors: Vec<_> = errors
2052 .iter()
2053 .filter(|e| {
2054 matches!(
2055 e.code,
2056 ErrorCode::InsufficientUnits
2057 | ErrorCode::NoMatchingLot
2058 | ErrorCode::AmbiguousLotMatch
2059 )
2060 })
2061 .collect();
2062 assert!(
2063 booking_errors.is_empty(),
2064 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
2065 );
2066 }
2067
2068 #[test]
2069 fn test_validate_successful_booking() {
2070 use rustledger_core::CostSpec;
2071
2072 let cost_spec = CostSpec::empty()
2073 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2074 .with_currency("USD");
2075
2076 let directives = vec![
2077 Directive::Open(
2078 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2079 ),
2080 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2081 Directive::Transaction(
2083 Transaction::new(date(2024, 1, 15), "Buy")
2084 .with_synthesized_posting(
2085 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2086 .with_cost(cost_spec.clone()),
2087 )
2088 .with_synthesized_posting(Posting::new(
2089 "Assets:Cash",
2090 Amount::new(dec!(-1500), "USD"),
2091 )),
2092 ),
2093 Directive::Transaction(
2095 Transaction::new(date(2024, 6, 1), "Sell")
2096 .with_synthesized_posting(
2097 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2098 .with_cost(cost_spec),
2099 )
2100 .with_synthesized_posting(Posting::new(
2101 "Assets:Cash",
2102 Amount::new(dec!(750), "USD"),
2103 )),
2104 ),
2105 ];
2106
2107 let errors = validate(&directives);
2108 let booking_errors: Vec<_> = errors
2110 .iter()
2111 .filter(|e| {
2112 matches!(
2113 e.code,
2114 ErrorCode::InsufficientUnits
2115 | ErrorCode::NoMatchingLot
2116 | ErrorCode::AmbiguousLotMatch
2117 )
2118 })
2119 .collect();
2120 assert!(
2121 booking_errors.is_empty(),
2122 "Should have no booking errors: {booking_errors:?}"
2123 );
2124 }
2125
2126 #[test]
2127 fn test_validate_account_already_open() {
2128 let directives = vec![
2129 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2130 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2132
2133 let errors = validate(&directives);
2134 assert!(
2135 errors
2136 .iter()
2137 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2138 "Should error for duplicate open: {errors:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_validate_account_close_not_empty() {
2144 let directives = vec![
2145 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2146 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2147 Directive::Transaction(
2148 Transaction::new(date(2024, 1, 15), "Deposit")
2149 .with_synthesized_posting(Posting::new(
2150 "Assets:Bank",
2151 Amount::new(dec!(100.00), "USD"),
2152 ))
2153 .with_synthesized_posting(Posting::new(
2154 "Income:Salary",
2155 Amount::new(dec!(-100.00), "USD"),
2156 )),
2157 ),
2158 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2160
2161 let errors = validate(&directives);
2162 assert!(
2163 errors
2164 .iter()
2165 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2166 "Should warn for closing account with balance: {errors:?}"
2167 );
2168 }
2169
2170 #[test]
2171 fn test_validate_no_postings_allowed() {
2172 let directives = vec![
2175 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2176 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2177 ];
2178
2179 let errors = validate(&directives);
2180 assert!(
2181 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2182 "Should NOT error for transaction with no postings: {errors:?}"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_validate_single_posting() {
2188 let directives = vec![
2189 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2190 Directive::Transaction(
2191 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2192 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2193 ),
2194 ),
2195 ];
2196
2197 let errors = validate(&directives);
2198 assert!(
2199 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2200 "Should warn for transaction with single posting: {errors:?}"
2201 );
2202 assert!(ErrorCode::SinglePosting.is_warning());
2204 }
2205
2206 #[test]
2207 fn test_validate_single_posting_zero_cost_no_warning() {
2208 let directives = vec![
2212 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2213 Directive::Transaction(
2214 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2215 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2216 rustledger_core::CostSpec::empty()
2217 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2218 .with_currency("USD"),
2219 ),
2220 ),
2221 ),
2222 ];
2223
2224 let errors = validate(&directives);
2225 assert!(
2226 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2227 "Should NOT warn for zero-cost single posting: {errors:?}"
2228 );
2229 }
2230
2231 #[test]
2232 fn test_validate_single_posting_nonzero_cost_still_warns() {
2233 let directives = vec![
2235 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2236 Directive::Transaction(
2237 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2238 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2239 rustledger_core::CostSpec::empty()
2240 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2241 .with_currency("USD"),
2242 ),
2243 ),
2244 ),
2245 ];
2246
2247 let errors = validate(&directives);
2248 assert!(
2249 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2250 "Should warn for single posting with non-zero cost: {errors:?}"
2251 );
2252 }
2253
2254 #[test]
2255 fn test_validate_pad_without_balance() {
2256 let directives = vec![
2257 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2258 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2259 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2260 ];
2262
2263 let errors = validate(&directives);
2264 assert!(
2265 errors
2266 .iter()
2267 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2268 "Should error for pad without subsequent balance: {errors:?}"
2269 );
2270 }
2271
2272 #[test]
2273 fn test_validate_multiple_pads_for_balance() {
2274 let directives = vec![
2275 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2276 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2277 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2278 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2280 date(2024, 1, 3),
2281 "Assets:Bank",
2282 Amount::new(dec!(1000.00), "USD"),
2283 )),
2284 ];
2285
2286 let errors = validate(&directives);
2287 assert!(
2288 errors
2289 .iter()
2290 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2291 "Should error for multiple pads before balance: {errors:?}"
2292 );
2293 }
2294
2295 #[test]
2296 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2297 let directives = vec![
2303 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2304 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2305 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2307 Directive::Balance(Balance::new(
2308 date(2024, 1, 2),
2309 "Assets:Bank",
2310 Amount::new(dec!(100.00), "USD"),
2311 )),
2312 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2315 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2316 Directive::Balance(Balance::new(
2317 date(2024, 2, 3),
2318 "Assets:Bank",
2319 Amount::new(dec!(200.00), "USD"),
2320 )),
2321 ];
2322
2323 let errors = validate(&directives);
2324 let multi_pad_count = errors
2325 .iter()
2326 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2327 .count();
2328 assert_eq!(
2329 multi_pad_count, 1,
2330 "E2004 must fire exactly once on the second balance; got {errors:?}"
2331 );
2332 }
2333
2334 #[test]
2335 fn test_pad_serves_multi_currency_balances_on_same_day() {
2336 let directives = vec![
2343 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2344 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2345 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2346 Directive::Balance(Balance::new(
2348 date(2024, 1, 2),
2349 "Assets:Bank",
2350 Amount::new(dec!(100.00), "USD"),
2351 )),
2352 Directive::Balance(Balance::new(
2353 date(2024, 1, 2),
2354 "Assets:Bank",
2355 Amount::new(dec!(50.00), "EUR"),
2356 )),
2357 ];
2358
2359 let errors = validate(&directives);
2360 assert!(
2361 !errors
2362 .iter()
2363 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2364 "pad should serve both USD and EUR; got {errors:?}"
2365 );
2366 assert!(
2367 !errors
2368 .iter()
2369 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2370 "pad serves at least one balance; should not be E2003; got {errors:?}"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2376 let directives = vec![
2381 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2382 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2383 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2384 Directive::Balance(Balance::new(
2385 date(2024, 1, 2),
2386 "Assets:Bank",
2387 Amount::new(dec!(100.00), "USD"),
2388 )),
2389 ];
2390
2391 let errors = validate(&directives);
2392 assert!(
2396 errors
2397 .iter()
2398 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2399 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2400 );
2401 assert!(
2403 errors
2404 .iter()
2405 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2406 "same-day pad never consumed; expected E2003; got {errors:?}"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_future_pad_does_not_apply_to_earlier_balance() {
2412 let directives = vec![
2418 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2419 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2420 Directive::Balance(Balance::new(
2421 date(2024, 1, 2),
2422 "Assets:Bank",
2423 Amount::new(dec!(0.00), "USD"),
2424 )),
2425 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2426 ];
2427
2428 let errors = validate(&directives);
2429 assert!(
2432 !errors
2433 .iter()
2434 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2435 "future pad should not influence earlier balance; got {errors:?}"
2436 );
2437 assert!(
2439 errors
2440 .iter()
2441 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2442 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2443 );
2444 }
2445
2446 #[test]
2447 fn test_error_severity() {
2448 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2450 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2451 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2452
2453 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2455 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2456 assert_eq!(
2457 ErrorCode::AccountCloseNotEmpty.severity(),
2458 Severity::Warning
2459 );
2460
2461 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2463 }
2464
2465 #[test]
2466 fn test_validate_invalid_account_name() {
2467 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2469
2470 let errors = validate(&directives);
2471 assert!(
2472 errors
2473 .iter()
2474 .any(|e| e.code == ErrorCode::InvalidAccountName),
2475 "Should error for invalid account root: {errors:?}"
2476 );
2477 }
2478
2479 #[test]
2480 fn test_validate_account_lowercase_component() {
2481 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2483
2484 let errors = validate(&directives);
2485 assert!(
2486 errors
2487 .iter()
2488 .any(|e| e.code == ErrorCode::InvalidAccountName),
2489 "Should error for lowercase component: {errors:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_validate_valid_account_names() {
2495 let valid_names = [
2497 "Assets:Bank",
2498 "Assets:Bank:Checking",
2499 "Liabilities:CreditCard",
2500 "Equity:Opening-Balances",
2501 "Income:Salary2024",
2502 "Expenses:Food:Restaurant",
2503 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2509
2510 for name in valid_names {
2511 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2512
2513 let errors = validate(&directives);
2514 let name_errors: Vec<_> = errors
2515 .iter()
2516 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2517 .collect();
2518 assert!(
2519 name_errors.is_empty(),
2520 "Should accept valid account name '{name}': {name_errors:?}"
2521 );
2522 }
2523 }
2524
2525 #[test]
2530 fn test_e2002_balance_exceeds_explicit_tolerance() {
2531 let directives = vec![
2534 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2535 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2536 Directive::Transaction(
2537 Transaction::new(date(2024, 1, 15), "Deposit")
2538 .with_synthesized_posting(Posting::new(
2539 "Assets:Bank",
2540 Amount::new(dec!(1000.00), "USD"),
2541 ))
2542 .with_synthesized_posting(Posting::new(
2543 "Income:Salary",
2544 Amount::new(dec!(-1000.00), "USD"),
2545 )),
2546 ),
2547 Directive::Balance(
2550 Balance::new(
2551 date(2024, 1, 16),
2552 "Assets:Bank",
2553 Amount::new(dec!(999.00), "USD"),
2554 )
2555 .with_tolerance(dec!(0.01)),
2556 ),
2557 ];
2558
2559 let errors = validate(&directives);
2560
2561 assert!(
2562 errors
2563 .iter()
2564 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2565 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2566 );
2567 }
2568
2569 #[test]
2570 fn test_e2002_balance_within_explicit_tolerance_passes() {
2571 let directives = vec![
2573 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2574 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2575 Directive::Transaction(
2576 Transaction::new(date(2024, 1, 15), "Deposit")
2577 .with_synthesized_posting(Posting::new(
2578 "Assets:Bank",
2579 Amount::new(dec!(1000.00), "USD"),
2580 ))
2581 .with_synthesized_posting(Posting::new(
2582 "Income:Salary",
2583 Amount::new(dec!(-1000.00), "USD"),
2584 )),
2585 ),
2586 Directive::Balance(
2588 Balance::new(
2589 date(2024, 1, 16),
2590 "Assets:Bank",
2591 Amount::new(dec!(999.00), "USD"),
2592 )
2593 .with_tolerance(dec!(5.00)),
2594 ),
2595 ];
2596
2597 let errors = validate(&directives);
2598
2599 assert!(
2600 !errors
2601 .iter()
2602 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2603 || e.code == ErrorCode::BalanceAssertionFailed),
2604 "Expected no balance errors, got: {errors:?}"
2605 );
2606 }
2607
2608 #[test]
2609 fn test_e5001_undeclared_currency() {
2610 use rustledger_core::Commodity;
2613
2614 let directives = vec![
2615 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2616 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2617 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2618 Directive::Transaction(
2619 Transaction::new(date(2024, 1, 15), "Lunch")
2620 .with_synthesized_posting(Posting::new(
2621 "Expenses:Food",
2622 Amount::new(dec!(20.00), "EUR"), ))
2624 .with_synthesized_posting(Posting::new(
2625 "Assets:Bank",
2626 Amount::new(dec!(-20.00), "EUR"),
2627 )),
2628 ),
2629 ];
2630
2631 let options = ValidationOptions::default().with_require_commodities(true);
2632 let errors = validate_with_options(&directives, options);
2633
2634 assert!(
2635 errors
2636 .iter()
2637 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2638 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2639 );
2640 }
2641
2642 #[test]
2643 fn test_e5001_declared_currency_passes() {
2644 use rustledger_core::Commodity;
2646
2647 let directives = vec![
2648 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2649 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2650 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2651 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2652 Directive::Transaction(
2653 Transaction::new(date(2024, 1, 15), "Lunch")
2654 .with_synthesized_posting(Posting::new(
2655 "Expenses:Food",
2656 Amount::new(dec!(20.00), "EUR"),
2657 ))
2658 .with_synthesized_posting(Posting::new(
2659 "Assets:Bank",
2660 Amount::new(dec!(-20.00), "EUR"),
2661 )),
2662 ),
2663 ];
2664
2665 let options = ValidationOptions::default().with_require_commodities(true);
2666 let errors = validate_with_options(&directives, options);
2667
2668 assert!(
2669 !errors
2670 .iter()
2671 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2672 "Expected no E5001 errors, got: {errors:?}"
2673 );
2674 }
2675
2676 #[test]
2677 fn test_e5001_not_raised_without_require_commodities() {
2678 let directives = vec![
2680 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2681 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2682 Directive::Transaction(
2683 Transaction::new(date(2024, 1, 15), "Lunch")
2684 .with_synthesized_posting(Posting::new(
2685 "Expenses:Food",
2686 Amount::new(dec!(20.00), "XYZ"), ))
2688 .with_synthesized_posting(Posting::new(
2689 "Assets:Bank",
2690 Amount::new(dec!(-20.00), "XYZ"),
2691 )),
2692 ),
2693 ];
2694
2695 let errors = validate(&directives);
2696
2697 assert!(
2698 !errors
2699 .iter()
2700 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2701 "Should not raise E5001 without require_commodities, got: {errors:?}"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_e3002_multiple_missing_amounts() {
2707 let directives = vec![
2709 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2710 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2711 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2712 Directive::Transaction(
2713 Transaction::new(date(2024, 1, 15), "Lunch")
2714 .with_synthesized_posting(Posting::new(
2715 "Assets:Bank",
2716 Amount::new(dec!(-50.00), "USD"),
2717 ))
2718 .with_synthesized_posting(Posting {
2720 account: "Expenses:Food".into(),
2721 units: None,
2722 cost: None,
2723 price: None,
2724 flag: None,
2725 meta: Default::default(),
2726 comments: vec![],
2727 trailing_comments: vec![],
2728 })
2729 .with_synthesized_posting(Posting {
2730 account: "Expenses:Drinks".into(),
2731 units: None,
2732 cost: None,
2733 price: None,
2734 flag: None,
2735 meta: Default::default(),
2736 comments: vec![],
2737 trailing_comments: vec![],
2738 }),
2739 ),
2740 ];
2741
2742 let errors = validate(&directives);
2743
2744 assert!(
2745 errors
2746 .iter()
2747 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2748 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_e3002_single_missing_amount_ok() {
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::Transaction(
2759 Transaction::new(date(2024, 1, 15), "Lunch")
2760 .with_synthesized_posting(Posting::new(
2761 "Assets:Bank",
2762 Amount::new(dec!(-50.00), "USD"),
2763 ))
2764 .with_synthesized_posting(Posting {
2765 account: "Expenses:Food".into(),
2766 units: None,
2767 cost: None,
2768 price: None,
2769 flag: None,
2770 meta: Default::default(),
2771 comments: vec![],
2772 trailing_comments: vec![],
2773 }),
2774 ),
2775 ];
2776
2777 let errors = validate(&directives);
2778
2779 assert!(
2780 !errors
2781 .iter()
2782 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2783 "Should not raise E3002 with single missing amount, got: {errors:?}"
2784 );
2785 }
2786
2787 #[test]
2788 fn test_e7001_unknown_option() {
2789 let state = LedgerState::new();
2791 let mut errors = Vec::new();
2792
2793 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2794
2795 assert_eq!(errors.len(), 1);
2796 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2797 assert!(errors[0].message.contains("bogus_option"));
2798 }
2799
2800 #[test]
2801 fn test_e7002_invalid_option_value() {
2802 let state = LedgerState::new();
2803 let mut errors = Vec::new();
2804
2805 state.import_option_warnings(
2806 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2807 &mut errors,
2808 );
2809
2810 assert_eq!(errors.len(), 1);
2811 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2812 }
2813
2814 #[test]
2815 fn test_e7003_duplicate_option() {
2816 let state = LedgerState::new();
2817 let mut errors = Vec::new();
2818
2819 state.import_option_warnings(
2820 &[("E7003", "Option \"title\" can only be specified once")],
2821 &mut errors,
2822 );
2823
2824 assert_eq!(errors.len(), 1);
2825 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2826 }
2827
2828 fn commodity_with_precision(value: MetaValue) -> Directive {
2831 let mut meta = rustledger_core::Metadata::default();
2832 meta.insert("precision".into(), value);
2833 Directive::Commodity(
2834 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2835 )
2836 }
2837
2838 #[test]
2839 fn precision_meta_valid_integer_emits_no_warning() {
2840 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2841 let errors = validate(&directives);
2842 assert!(
2843 errors
2844 .iter()
2845 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2846 "valid precision must not produce a warning, got: {errors:?}"
2847 );
2848 }
2849
2850 #[test]
2851 fn precision_meta_zero_is_valid() {
2852 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2853 let errors = validate(&directives);
2854 assert!(
2855 errors
2856 .iter()
2857 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2858 );
2859 }
2860
2861 #[test]
2862 fn precision_meta_negative_emits_e5003() {
2863 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2864 let errors = validate(&directives);
2865 let warnings: Vec<_> = errors
2866 .iter()
2867 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2868 .collect();
2869 assert_eq!(warnings.len(), 1, "expected one E5003");
2870 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2871 assert!(warnings[0].message.contains("non-negative"));
2872 }
2873
2874 #[test]
2875 fn precision_meta_non_integer_emits_e5003() {
2876 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2877 let errors = validate(&directives);
2878 let warnings: Vec<_> = errors
2879 .iter()
2880 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2881 .collect();
2882 assert_eq!(warnings.len(), 1);
2883 assert!(warnings[0].message.contains("integer"));
2884 }
2885
2886 #[test]
2887 fn precision_meta_string_value_emits_e5003() {
2888 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2889 let errors = validate(&directives);
2890 let warnings: Vec<_> = errors
2891 .iter()
2892 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2893 .collect();
2894 assert_eq!(warnings.len(), 1);
2895 assert!(warnings[0].message.contains("string"));
2896 }
2897
2898 #[test]
2899 fn precision_meta_out_of_u32_range_emits_e5003() {
2900 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2902 8589934592
2903 )))];
2904 let errors = validate(&directives);
2905 let warnings: Vec<_> = errors
2906 .iter()
2907 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2908 .collect();
2909 assert_eq!(warnings.len(), 1);
2910 assert!(warnings[0].message.contains("exceeds"));
2911 }
2912
2913 #[test]
2914 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2915 let directives = vec![
2920 commodity_with_precision(MetaValue::Number(dec!(2))),
2921 commodity_with_precision(MetaValue::Number(dec!(-1))),
2922 ];
2923 let warnings: Vec<_> = validate(&directives)
2924 .into_iter()
2925 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2926 .collect();
2927 assert_eq!(
2928 warnings.len(),
2929 1,
2930 "exactly one E5003 expected (only the invalid declaration)"
2931 );
2932 assert!(warnings[0].message.contains("non-negative"));
2933 }
2934
2935 #[test]
2936 fn precision_meta_e5003_is_warning_severity() {
2937 assert_eq!(
2941 ErrorCode::InvalidPrecisionMetadata.severity(),
2942 Severity::Warning
2943 );
2944 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2945 }
2946
2947 #[test]
2955 fn test_validate_early_emits_e1001_on_elided_posting() {
2956 let directives = vec![
2957 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2958 Directive::Transaction(
2959 Transaction::new(date(2024, 1, 15), "Zero to unopened")
2960 .with_synthesized_posting(Posting::new(
2961 "Assets:Bank",
2962 Amount::new(dec!(0.00), "USD"),
2963 ))
2964 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
2965 ),
2966 ];
2967
2968 let session = ValidationSession::new(ValidationOptions::default());
2969 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
2970
2971 assert!(
2972 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
2973 && e.to_string().contains("Expenses:NeverOpened")),
2974 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
2975 );
2976 }
2977
2978 #[test]
2982 fn test_validate_late_does_not_duplicate_e1001() {
2983 let directives = vec![
2984 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2985 Directive::Transaction(
2986 Transaction::new(date(2024, 1, 15), "To unopened")
2987 .with_synthesized_posting(Posting::new(
2988 "Assets:Bank",
2989 Amount::new(dec!(100), "USD"),
2990 ))
2991 .with_synthesized_posting(Posting::new(
2992 "Expenses:NeverOpened",
2993 Amount::new(dec!(-100), "USD"),
2994 )),
2995 ),
2996 ];
2997
2998 let session = ValidationSession::new(ValidationOptions::default());
2999 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
3000 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
3001
3002 let early_e1001 = early
3003 .iter()
3004 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3005 .count();
3006 let late_e1001 = late
3007 .iter()
3008 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3009 .count();
3010
3011 assert_eq!(
3012 early_e1001, 0,
3013 "explicit posting: early phase defers E1001 to late; got: {early:?}"
3014 );
3015 assert_eq!(
3016 late_e1001, 1,
3017 "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
3018 );
3019 }
3020
3021 #[test]
3027 fn test_validate_chained_matches_explicit_phases() {
3028 let directives = vec![
3032 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3033 Directive::Transaction(
3034 Transaction::new(date(2024, 1, 15), "Mixed")
3035 .with_synthesized_posting(Posting::new(
3036 "Assets:Bank",
3037 Amount::new(dec!(50), "USD"),
3038 ))
3039 .with_synthesized_posting(Posting::new(
3040 "Income:Salary",
3041 Amount::new(dec!(-50), "USD"),
3042 )),
3043 ),
3044 Directive::Balance(Balance::new(
3045 date(2024, 1, 16),
3046 "Assets:Bank",
3047 Amount::new(dec!(50), "USD"),
3048 )),
3049 ];
3050
3051 let chained = validate(&directives);
3053
3054 let session = ValidationSession::new(ValidationOptions::default());
3056 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3057 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3058 explicit.extend(late_errs);
3059 explicit.extend(session.finalize());
3060
3061 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3065 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3066 assert_eq!(
3067 chained_strs, explicit_strs,
3068 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3069 );
3070 }
3071
3072 #[test]
3073 fn test_phase_order_early_then_late_then_finalize() {
3074 let directives = vec![
3081 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3082 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3083 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3084 Directive::Transaction(
3086 Transaction::new(date(2024, 1, 5), "early")
3087 .with_synthesized_posting(Posting::new(
3088 "Assets:Bank",
3089 Amount::new(dec!(100), "USD"),
3090 ))
3091 .with_synthesized_posting(Posting::new(
3092 "Income:Salary",
3093 Amount::new(dec!(-100), "USD"),
3094 )),
3095 ),
3096 Directive::Pad(Pad::new(
3098 date(2024, 1, 10),
3099 "Assets:Other",
3100 "Equity:Opening",
3101 )),
3102 Directive::Balance(Balance::new(
3104 date(2024, 2, 1),
3105 "Assets:Bank",
3106 Amount::new(dec!(999), "USD"),
3107 )),
3108 ];
3109
3110 let errors = validate(&directives);
3111 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3112
3113 let early_pos = codes
3114 .iter()
3115 .position(|c| *c == ErrorCode::AccountNotOpen)
3116 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3117 let late_pos = codes
3118 .iter()
3119 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3120 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3121 let finalize_pos = codes
3122 .iter()
3123 .position(|c| *c == ErrorCode::PadWithoutBalance)
3124 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3125
3126 assert!(
3127 early_pos < late_pos,
3128 "early-phase errors must precede late-phase; got {codes:?}"
3129 );
3130 assert!(
3131 late_pos < finalize_pos,
3132 "late-phase errors must precede finalize; got {codes:?}"
3133 );
3134 }
3135
3136 #[test]
3137 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3138 let directives = vec![
3145 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3146 Directive::Transaction(
3149 Transaction::new(date(2024, 1, 10), "leave residue")
3150 .with_synthesized_posting(Posting::new(
3151 "Assets:Bank",
3152 Amount::new(dec!(50), "USD"),
3153 ))
3154 .with_synthesized_posting(Posting::new(
3155 "Equity:Opening",
3156 Amount::new(dec!(-50), "USD"),
3157 )),
3158 ),
3159 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3160 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3161 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3162 ];
3163
3164 let errors = validate(&directives);
3165 let close_not_empty_count = errors
3166 .iter()
3167 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3168 .count();
3169 assert_eq!(
3170 close_not_empty_count, 1,
3171 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3172 );
3173 let account_closed_count = errors
3175 .iter()
3176 .filter(|e| e.code == ErrorCode::AccountClosed)
3177 .count();
3178 assert_eq!(
3179 account_closed_count, 1,
3180 "duplicate close should still report AccountClosed once; got {errors:?}"
3181 );
3182 }
3183
3184 #[test]
3210 fn typestate_pins_phase_ordering_at_compile_time() {
3211 fn _expect_pending_returns_early(
3223 s: ValidationSession<Pending>,
3224 ) -> ValidationSession<EarlyDone> {
3225 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3226 s
3227 }
3228 fn _expect_early_returns_late(
3229 s: ValidationSession<EarlyDone>,
3230 ) -> ValidationSession<LateDone> {
3231 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3232 s
3233 }
3234 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3235 s.finalize()
3236 }
3237 }
3238}