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, Default)]
300pub struct LedgerState {
301 accounts: FxHashMap<rustledger_core::Account, AccountState>,
303 inventories: FxHashMap<rustledger_core::Account, Inventory>,
305 inventory_accounts: BTreeSet<Account>,
312 commodities: FxHashSet<rustledger_core::Currency>,
314 pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
316 options: ValidationOptions,
318 last_date: Option<NaiveDate>,
320 pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
331 pub(crate) account_not_open_early: FxHashSet<(u16, rustledger_core::Span)>,
343}
344
345impl LedgerState {
346 #[must_use]
348 pub fn new() -> Self {
349 Self::default()
350 }
351
352 #[must_use]
354 pub fn with_options(options: ValidationOptions) -> Self {
355 Self {
356 options,
357 ..Default::default()
358 }
359 }
360
361 pub const fn set_require_commodities(&mut self, require: bool) {
363 self.options.require_commodities = require;
364 }
365
366 pub const fn set_check_documents(&mut self, check: bool) {
368 self.options.check_documents = check;
369 }
370
371 pub const fn set_warn_future_dates(&mut self, warn: bool) {
373 self.options.warn_future_dates = warn;
374 }
375
376 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
378 self.options.document_base = Some(base.into());
379 }
380
381 #[must_use]
383 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
384 self.inventories.get(account)
385 }
386
387 pub fn accounts(&self) -> impl Iterator<Item = &str> {
389 self.accounts.keys().map(rustledger_core::Account::as_str)
390 }
391
392 pub fn import_option_warnings(
400 &self,
401 warnings: &[(&str, &str)],
402 errors: &mut Vec<ValidationError>,
403 ) {
404 for &(code, message) in warnings {
405 let error_code = match code {
406 "E7001" => ErrorCode::UnknownOption,
407 "E7002" => ErrorCode::InvalidOptionValue,
408 "E7003" => ErrorCode::DuplicateOption,
409 _ => continue,
410 };
411 errors.push(ValidationError::new(
412 error_code,
413 message.to_string(),
414 NaiveDate::default(),
416 ));
417 }
418 }
419}
420
421trait ValidatableDirective: Sync {
430 fn directive(&self) -> &Directive;
431 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
435}
436
437impl ValidatableDirective for Directive {
438 fn directive(&self) -> &Directive {
439 self
440 }
441 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
442 None
443 }
444}
445
446impl ValidatableDirective for Spanned<Directive> {
447 fn directive(&self) -> &Directive {
448 &self.value
449 }
450 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
451 Some((self.span, self.file_id))
452 }
453}
454
455fn sum_account_subtree(
469 inventories: &FxHashMap<Account, Inventory>,
470 index: &BTreeSet<Account>,
471 account: &Account,
472 currency: &Currency,
473) -> Decimal {
474 let acct = account.as_str();
475 let mut total = inventories
477 .get(account)
478 .map_or(Decimal::ZERO, |inv| inv.units(currency));
479 let mut lower = String::with_capacity(acct.len() + 1);
484 lower.push_str(acct);
485 lower.push(':');
486 let mut upper = String::with_capacity(acct.len() + 1);
487 upper.push_str(acct);
488 upper.push(';');
489 let bounds = (
490 std::ops::Bound::Included(lower.as_str()),
491 std::ops::Bound::Excluded(upper.as_str()),
492 );
493 for sub in index.range::<str, _>(bounds) {
494 if let Some(inv) = inventories.get(sub) {
495 total += inv.units(currency);
496 }
497 }
498 total
499}
500
501fn validate_phase_inner<D: ValidatableDirective>(
512 directives: &[D],
513 state: &mut LedgerState,
514 phase: Phase,
515 today: NaiveDate,
516) -> Vec<ValidationError> {
517 let document_exists_cache = if phase == Phase::Early {
520 build_document_exists_cache(directives, &state.options)
521 } else {
522 FxHashMap::default()
523 };
524
525 if phase == Phase::Early {
529 state.last_date = None;
530 }
531
532 let mut errors = Vec::new();
533
534 let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
539 sorted.extend(directives.iter());
540 let sort_fn = |a: &&D, b: &&D| {
541 let ad = a.directive();
542 let bd = b.directive();
543 ad.date()
544 .cmp(&bd.date())
545 .then_with(|| ad.priority().cmp(&bd.priority()))
546 .then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
547 };
548 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
549 sorted.par_sort_by(sort_fn);
550 } else {
551 sorted.sort_by(sort_fn);
552 }
553
554 for d in sorted {
555 let directive = d.directive();
556 let date = directive.date();
557
558 let error_count_before = errors.len();
565
566 if phase == Phase::Early {
570 if let Some(last) = state.last_date
571 && date < last
572 {
573 errors.push(ValidationError::new(
574 ErrorCode::DateOutOfOrder,
575 format!("Directive date {date} is before previous directive {last}"),
576 date,
577 ));
578 }
579 state.last_date = Some(date);
580
581 if state.options.warn_future_dates && date > today {
582 errors.push(ValidationError::new(
583 ErrorCode::FutureDate,
584 format!("Entry dated in the future: {date}"),
585 date,
586 ));
587 }
588 }
589
590 match (phase, directive) {
591 (Phase::Early, Directive::Open(open)) => {
593 validate_open(state, open, &mut errors);
594 }
595 (Phase::Late, Directive::Open(open)) => {
599 register_open_late(state, open);
600 }
601 (Phase::Early, Directive::Close(close)) => {
602 validate_close(state, close, &mut errors);
603 }
604 (Phase::Late, Directive::Close(close)) => {
605 validate_close_late(state, close, &mut errors);
606 }
607 (Phase::Early, Directive::Commodity(comm)) => {
608 state.commodities.insert(comm.currency.clone());
609 validate_commodity_precision_meta(comm, &mut errors);
610 }
611 (Phase::Early, Directive::Pad(pad)) => {
612 validate_pad(state, pad, d.span_info(), &mut errors);
613 }
614 (Phase::Early, Directive::Document(doc)) => {
615 let file_id = d.span_info().map(|(_, fid)| fid);
616 validate_document(state, doc, file_id, &document_exists_cache, &mut errors);
617 }
618 (Phase::Early, Directive::Note(note)) => {
619 validate_note(state, note, &mut errors);
620 }
621 (Phase::Early, Directive::Transaction(txn)) => {
623 validate_transaction_early(state, txn, &mut errors);
624 }
625 (Phase::Late, Directive::Transaction(txn)) => {
626 validate_transaction_late(state, txn, &mut errors);
627 }
628 (Phase::Early, Directive::Balance(bal)) => {
629 validate_balance_early(state, bal, &mut errors);
630 }
631 (Phase::Late, Directive::Balance(bal)) => {
632 validate_balance_late(state, bal, &mut errors);
633 }
634 _ => {}
636 }
637
638 if let Some((span, file_id)) = d.span_info() {
645 for error in errors.iter_mut().skip(error_count_before) {
646 if error.span.is_none() {
647 error.span = Some(span);
648 error.file_id = Some(file_id);
649 }
650 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
651 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
652 }
653 }
654 }
655 }
656
657 errors
658}
659
660const SYNTHESIZED_DIRECTIVE_NOTE: &str = "directive was synthesized by a plugin (no source location \
668 in your files); the responsible plugin is either an \
669 enabled auto-plugin (e.g. `auto_accounts`, or document \
670 discovery via `option \"documents\"`) or one of your \
671 `plugin \"…\"` declarations";
672
673fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
674 let mut errors = Vec::new();
675 for (target_account, pads) in &state.pending_pads {
676 for pad in pads {
677 if pad.padded_currencies.is_empty() {
678 let mut error = ValidationError::new(
679 ErrorCode::PadWithoutBalance,
680 "Unused Pad entry".to_string(),
681 pad.date,
682 )
683 .with_context(format!(
684 " {} pad {} {}",
685 pad.date, target_account, pad.source_account
686 ));
687 if let Some((span, file_id)) = pad.location {
693 error.span = Some(span);
694 error.file_id = Some(file_id);
695 if file_id == SYNTHESIZED_FILE_ID {
696 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
697 }
698 }
699 errors.push(error);
700 }
701 }
702 }
703 errors
704}
705
706fn build_document_exists_cache<'a, D: ValidatableDirective>(
728 directives: &'a [D],
729 options: &ValidationOptions,
730) -> FxHashMap<(&'a str, Option<u16>), bool> {
731 if !options.check_documents {
732 return FxHashMap::default();
733 }
734
735 let mut keys: FxHashSet<(&str, Option<u16>)> = FxHashSet::default();
741 for d in directives {
742 if let Directive::Document(doc) = d.directive() {
743 let file_id = d.span_info().map(|(_, fid)| fid);
744 keys.insert((doc.path.as_str(), file_id));
745 }
746 }
747 let keys: Vec<(&str, Option<u16>)> = keys.into_iter().collect();
748
749 let resolve = |(s, file_id): (&'a str, Option<u16>)| {
756 ((s, file_id), document_file_exists(s, file_id, options))
757 };
758
759 if keys.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
760 keys.into_par_iter().map(resolve).collect()
761 } else {
762 keys.into_iter().map(resolve).collect()
763 }
764}
765
766fn document_file_exists(path: &str, file_id: Option<u16>, options: &ValidationOptions) -> bool {
776 let doc_path = std::path::Path::new(path);
777 if doc_path.is_absolute() {
778 doc_path.exists()
779 } else if let Some(base) = &options.document_base {
780 base.join(doc_path).exists()
781 } else if !options.document_dirs.is_empty() {
782 options
783 .document_dirs
784 .iter()
785 .any(|dir| dir.join(doc_path).exists())
786 } else if let Some(dir) = file_id.and_then(|id| options.document_source_dirs.get(id as usize)) {
787 dir.join(doc_path).exists()
788 } else {
789 doc_path.exists()
790 }
791}
792
793pub mod phase {
838 mod sealed {
839 pub trait Sealed {}
840 }
841
842 pub trait SessionPhase: sealed::Sealed {}
845
846 macro_rules! define_phase {
847 ($name:ident, $doc:expr) => {
848 #[doc = $doc]
849 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
850 pub struct $name;
851 impl sealed::Sealed for $name {}
852 impl SessionPhase for $name {}
853 };
854 }
855
856 define_phase!(
857 Pending,
858 "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
859 );
860 define_phase!(
861 EarlyDone,
862 "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
863 );
864 define_phase!(
865 LateDone,
866 "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
867 );
868}
869
870pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
871
872pub struct ValidationSession<P: SessionPhase = Pending> {
945 state: LedgerState,
946 _phase: std::marker::PhantomData<P>,
947}
948
949impl ValidationSession<Pending> {
950 #[must_use]
955 pub fn new(options: ValidationOptions) -> Self {
956 Self {
957 state: LedgerState::with_options(options),
958 _phase: std::marker::PhantomData,
959 }
960 }
961
962 #[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"]
973 pub fn run_early(
974 self,
975 directives: &[Directive],
976 today: NaiveDate,
977 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
978 self.run_phase_internal(directives, Phase::Early, today)
979 }
980
981 #[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"]
985 pub fn run_early_spanned(
986 self,
987 directives: &[Spanned<Directive>],
988 today: NaiveDate,
989 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
990 self.run_phase_internal(directives, Phase::Early, today)
991 }
992
993 fn run_phase_internal<D: ValidatableDirective>(
1001 mut self,
1002 directives: &[D],
1003 phase: Phase,
1004 today: NaiveDate,
1005 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1006 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1007 (
1008 ValidationSession {
1009 state: self.state,
1010 _phase: std::marker::PhantomData,
1011 },
1012 errors,
1013 )
1014 }
1015}
1016
1017impl ValidationSession<EarlyDone> {
1018 #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1028 pub fn run_late(
1029 self,
1030 directives: &[Directive],
1031 today: NaiveDate,
1032 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1033 self.run_phase_internal(directives, Phase::Late, today)
1034 }
1035
1036 #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1040 pub fn run_late_spanned(
1041 self,
1042 directives: &[Spanned<Directive>],
1043 today: NaiveDate,
1044 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1045 self.run_phase_internal(directives, Phase::Late, today)
1046 }
1047
1048 fn run_phase_internal<D: ValidatableDirective>(
1052 mut self,
1053 directives: &[D],
1054 phase: Phase,
1055 today: NaiveDate,
1056 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1057 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1058 (
1059 ValidationSession {
1060 state: self.state,
1061 _phase: std::marker::PhantomData,
1062 },
1063 errors,
1064 )
1065 }
1066}
1067
1068impl ValidationSession<LateDone> {
1069 #[must_use]
1073 pub fn finalize(self) -> Vec<ValidationError> {
1074 check_unused_pads(&self.state)
1075 }
1076}
1077
1078fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
1084 let Some(value) = comm.meta.get("precision") else {
1085 return;
1086 };
1087 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
1088 errors.push(ValidationError::new(
1089 ErrorCode::InvalidPrecisionMetadata,
1090 format!(
1091 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
1092 comm.currency
1093 ),
1094 comm.date,
1095 ));
1096 }
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101 use super::*;
1102 use rust_decimal_macros::dec;
1103 use rustledger_core::{
1104 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
1105 };
1106
1107 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1108 rustledger_core::naive_date(year, month, day).unwrap()
1109 }
1110
1111 fn test_today() -> NaiveDate {
1115 date(2030, 1, 1)
1116 }
1117
1118 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
1123 validate_with_options(directives, ValidationOptions::default())
1124 }
1125
1126 fn validate_with_options(
1129 directives: &[Directive],
1130 options: ValidationOptions,
1131 ) -> Vec<ValidationError> {
1132 validate_with_today(directives, options, test_today())
1133 }
1134
1135 fn validate_with_today(
1139 directives: &[Directive],
1140 options: ValidationOptions,
1141 today: NaiveDate,
1142 ) -> Vec<ValidationError> {
1143 let session = ValidationSession::new(options);
1144 let (session, mut errors) = session.run_early(directives, today);
1145 let (session, late_errors) = session.run_late(directives, today);
1146 errors.extend(late_errors);
1147 errors.extend(session.finalize());
1148 errors
1149 }
1150
1151 #[test]
1152 fn sum_account_subtree_matches_scan_and_excludes_prefix_siblings() {
1153 let mut state = LedgerState::default();
1155 let fixture = [
1156 ("Assets:Bank", dec!(10)),
1157 ("Assets:Bank:Checking", dec!(40)),
1158 ("Assets:Bank:Savings", dec!(5)),
1159 ("Assets:BankAlias", dec!(99)), ("Assets:Other", dec!(7)),
1161 ];
1162 for (name, amt) in fixture {
1163 let acct = Account::from(name);
1164 let mut inv = Inventory::new();
1165 inv.add(rustledger_core::Position::simple(Amount::new(amt, "USD")));
1166 state.inventories.insert(acct.clone(), inv);
1167 state.inventory_accounts.insert(acct);
1168 }
1169
1170 let cur = Currency::from("USD");
1171 for name in [
1173 "Assets:Bank",
1174 "Assets:Bank:Checking",
1175 "Assets:BankAlias",
1176 "Assets:Other",
1177 "Assets:Missing",
1178 ] {
1179 let acct = Account::from(name);
1180 let indexed =
1181 sum_account_subtree(&state.inventories, &state.inventory_accounts, &acct, &cur);
1182 let scan =
1183 rustledger_core::sum_account_and_subaccounts(state.inventories.iter(), name, &cur);
1184 assert_eq!(indexed, scan, "indexed vs scan disagree for {name}");
1185 }
1186
1187 let bank = sum_account_subtree(
1189 &state.inventories,
1190 &state.inventory_accounts,
1191 &Account::from("Assets:Bank"),
1192 &cur,
1193 );
1194 assert_eq!(
1195 bank,
1196 dec!(55),
1197 "Assets:Bank must sum its subtree, excluding the Assets:BankAlias prefix sibling"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_validate_account_lifecycle() {
1203 let directives = vec![
1204 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1205 Directive::Transaction(
1206 Transaction::new(date(2024, 1, 15), "Test")
1207 .with_synthesized_posting(Posting::new(
1208 "Assets:Bank",
1209 Amount::new(dec!(100), "USD"),
1210 ))
1211 .with_synthesized_posting(Posting::new(
1212 "Income:Salary",
1213 Amount::new(dec!(-100), "USD"),
1214 )),
1215 ),
1216 ];
1217
1218 let errors = validate(&directives);
1219
1220 assert!(errors
1222 .iter()
1223 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1224 }
1225
1226 #[test]
1227 fn test_validate_account_used_before_open() {
1228 let directives = vec![
1229 Directive::Transaction(
1230 Transaction::new(date(2024, 1, 1), "Test")
1231 .with_synthesized_posting(Posting::new(
1232 "Assets:Bank",
1233 Amount::new(dec!(100), "USD"),
1234 ))
1235 .with_synthesized_posting(Posting::new(
1236 "Income:Salary",
1237 Amount::new(dec!(-100), "USD"),
1238 )),
1239 ),
1240 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1241 ];
1242
1243 let errors = validate(&directives);
1244
1245 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1246 }
1247
1248 #[test]
1249 fn test_validate_account_used_after_close() {
1250 let directives = vec![
1251 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1252 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1253 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1254 Directive::Transaction(
1255 Transaction::new(date(2024, 7, 1), "Test")
1256 .with_synthesized_posting(Posting::new(
1257 "Assets:Bank",
1258 Amount::new(dec!(-50), "USD"),
1259 ))
1260 .with_synthesized_posting(Posting::new(
1261 "Expenses:Food",
1262 Amount::new(dec!(50), "USD"),
1263 )),
1264 ),
1265 ];
1266
1267 let errors = validate(&directives);
1268
1269 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1270 }
1271
1272 #[test]
1273 fn test_validate_balance_assertion() {
1274 let directives = vec![
1275 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1276 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1277 Directive::Transaction(
1278 Transaction::new(date(2024, 1, 15), "Deposit")
1279 .with_synthesized_posting(Posting::new(
1280 "Assets:Bank",
1281 Amount::new(dec!(1000.00), "USD"),
1282 ))
1283 .with_synthesized_posting(Posting::new(
1284 "Income:Salary",
1285 Amount::new(dec!(-1000.00), "USD"),
1286 )),
1287 ),
1288 Directive::Balance(Balance::new(
1289 date(2024, 1, 16),
1290 "Assets:Bank",
1291 Amount::new(dec!(1000.00), "USD"),
1292 )),
1293 ];
1294
1295 let errors = validate(&directives);
1296 assert!(errors.is_empty(), "{errors:?}");
1297 }
1298
1299 #[test]
1300 fn test_validate_balance_assertion_failed() {
1301 let directives = vec![
1302 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1303 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1304 Directive::Transaction(
1305 Transaction::new(date(2024, 1, 15), "Deposit")
1306 .with_synthesized_posting(Posting::new(
1307 "Assets:Bank",
1308 Amount::new(dec!(1000.00), "USD"),
1309 ))
1310 .with_synthesized_posting(Posting::new(
1311 "Income:Salary",
1312 Amount::new(dec!(-1000.00), "USD"),
1313 )),
1314 ),
1315 Directive::Balance(Balance::new(
1316 date(2024, 1, 16),
1317 "Assets:Bank",
1318 Amount::new(dec!(500.00), "USD"), )),
1320 ];
1321
1322 let errors = validate(&directives);
1323 assert!(
1324 errors
1325 .iter()
1326 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1327 );
1328 }
1329
1330 #[test]
1336 fn test_validate_balance_assertion_within_tolerance() {
1337 let directives = vec![
1342 Directive::Open(
1343 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1344 ),
1345 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1346 Directive::Transaction(
1347 Transaction::new(date(2024, 1, 15), "Deposit")
1348 .with_synthesized_posting(Posting::new(
1349 "Assets:Bank",
1350 Amount::new(dec!(70.538), "ABC"), ))
1352 .with_synthesized_posting(Posting::new(
1353 "Expenses:Misc",
1354 Amount::new(dec!(-70.538), "ABC"),
1355 )),
1356 ),
1357 Directive::Balance(Balance::new(
1358 date(2024, 1, 16),
1359 "Assets:Bank",
1360 Amount::new(dec!(70.53), "ABC"), )),
1362 ];
1363
1364 let errors = validate(&directives);
1365 assert!(
1366 errors.is_empty(),
1367 "Balance within tolerance should pass: {errors:?}"
1368 );
1369 }
1370
1371 #[test]
1373 fn test_validate_balance_assertion_exceeds_tolerance() {
1374 let directives = vec![
1379 Directive::Open(
1380 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1381 ),
1382 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1383 Directive::Transaction(
1384 Transaction::new(date(2024, 1, 15), "Deposit")
1385 .with_synthesized_posting(Posting::new(
1386 "Assets:Bank",
1387 Amount::new(dec!(70.542), "ABC"),
1388 ))
1389 .with_synthesized_posting(Posting::new(
1390 "Expenses:Misc",
1391 Amount::new(dec!(-70.542), "ABC"),
1392 )),
1393 ),
1394 Directive::Balance(Balance::new(
1395 date(2024, 1, 16),
1396 "Assets:Bank",
1397 Amount::new(dec!(70.53), "ABC"), )),
1399 ];
1400
1401 let errors = validate(&directives);
1402 assert!(
1403 errors
1404 .iter()
1405 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1406 "Balance exceeding tolerance should fail"
1407 );
1408 }
1409
1410 #[test]
1411 fn test_validate_unbalanced_transaction() {
1412 let directives = vec![
1413 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1414 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1415 Directive::Transaction(
1416 Transaction::new(date(2024, 1, 15), "Unbalanced")
1417 .with_synthesized_posting(Posting::new(
1418 "Assets:Bank",
1419 Amount::new(dec!(-50.00), "USD"),
1420 ))
1421 .with_synthesized_posting(Posting::new(
1422 "Expenses:Food",
1423 Amount::new(dec!(40.00), "USD"),
1424 )), ),
1426 ];
1427
1428 let errors = validate(&directives);
1429 assert!(
1430 errors
1431 .iter()
1432 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1433 );
1434 }
1435
1436 #[test]
1437 fn test_validate_currency_not_allowed() {
1438 let directives = vec![
1439 Directive::Open(
1440 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1441 ),
1442 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1443 Directive::Transaction(
1444 Transaction::new(date(2024, 1, 15), "Test")
1445 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_synthesized_posting(Posting::new(
1447 "Income:Salary",
1448 Amount::new(dec!(-100.00), "EUR"),
1449 )),
1450 ),
1451 ];
1452
1453 let errors = validate(&directives);
1454 assert!(
1455 errors
1456 .iter()
1457 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1458 );
1459 }
1460
1461 #[test]
1462 fn test_validate_future_date_warning() {
1463 let today = date(2024, 1, 1);
1467 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1468
1469 let directives = vec![Directive::Open(Open {
1470 date: future_date,
1471 account: "Assets:Bank".into(),
1472 currencies: vec![],
1473 booking: None,
1474 meta: Default::default(),
1475 })];
1476
1477 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1479 assert!(
1480 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1481 "Should not warn about future dates by default"
1482 );
1483
1484 let options = ValidationOptions::default().with_warn_future_dates(true);
1486 let errors = validate_with_today(&directives, options, today);
1487 assert!(
1488 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1489 "Should warn about future dates when enabled"
1490 );
1491 }
1492
1493 #[test]
1500 fn test_validate_with_today_threads_today_parameter() {
1501 let directives = vec![Directive::Open(Open {
1502 date: date(2024, 6, 15),
1503 account: "Assets:Bank".into(),
1504 currencies: vec![],
1505 booking: None,
1506 meta: Default::default(),
1507 })];
1508 let options = ValidationOptions::default().with_warn_future_dates(true);
1509
1510 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1512 assert!(
1513 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1514 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1515 );
1516
1517 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1519 assert!(
1520 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1521 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_validate_document_not_found() {
1527 let directives = vec![
1528 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1529 Directive::Document(Document {
1530 date: date(2024, 1, 15),
1531 account: "Assets:Bank".into(),
1532 path: "/nonexistent/path/to/document.pdf".to_string(),
1533 tags: vec![],
1534 links: vec![],
1535 meta: Default::default(),
1536 }),
1537 ];
1538
1539 let errors = validate(&directives);
1541 assert!(
1542 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1543 "Should check documents by default"
1544 );
1545
1546 let options = ValidationOptions::default().with_check_documents(false);
1548 let errors = validate_with_options(&directives, options);
1549 assert!(
1550 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1551 "Should not report missing document when disabled"
1552 );
1553 }
1554
1555 #[test]
1556 fn test_validate_document_account_not_open() {
1557 let directives = vec![Directive::Document(Document {
1558 date: date(2024, 1, 15),
1559 account: "Assets:Unknown".into(),
1560 path: "receipt.pdf".to_string(),
1561 tags: vec![],
1562 links: vec![],
1563 meta: Default::default(),
1564 })];
1565
1566 let errors = validate(&directives);
1567 assert!(
1568 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1569 "Should error for document on unopened account"
1570 );
1571 }
1572
1573 #[test]
1574 fn test_validate_document_relative_path_in_document_dirs() {
1575 let filename = "rustledger_test_889_relative_receipt.pdf";
1579 let dir = tempfile::tempdir().unwrap();
1580 let doc_subdir = dir.path().join("documents");
1581 std::fs::create_dir_all(&doc_subdir).unwrap();
1582 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1583
1584 let directives = vec![
1585 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1586 Directive::Document(Document {
1587 date: date(2024, 1, 15),
1588 account: "Assets:Bank".into(),
1589 path: filename.to_string(),
1590 tags: vec![],
1591 links: vec![],
1592 meta: Default::default(),
1593 }),
1594 ];
1595
1596 let errors = validate(&directives);
1598 assert!(
1599 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1600 "Should error when document_dirs not set"
1601 );
1602
1603 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1605 let errors = validate_with_options(&directives, options);
1606 assert!(
1607 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1608 "Should find document in document_dirs: {errors:?}"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_validate_document_relative_path_not_found_in_dirs() {
1614 let filename = "rustledger_test_889_nonexistent.pdf";
1616 let dir = tempfile::tempdir().unwrap();
1617 let doc_subdir = dir.path().join("documents");
1618 std::fs::create_dir_all(&doc_subdir).unwrap();
1619
1620 let directives = vec![
1621 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1622 Directive::Document(Document {
1623 date: date(2024, 1, 15),
1624 account: "Assets:Bank".into(),
1625 path: filename.to_string(),
1626 tags: vec![],
1627 links: vec![],
1628 meta: Default::default(),
1629 }),
1630 ];
1631
1632 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1633 let errors = validate_with_options(&directives, options);
1634 assert!(
1635 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1636 "Should error when file not found in any document_dir"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_validate_document_absolute_path_ignores_document_dirs() {
1642 let filename = "rustledger_test_889_absolute_receipt.pdf";
1643 let dir = tempfile::tempdir().unwrap();
1644 let doc_subdir = dir.path().join("documents");
1645 std::fs::create_dir_all(&doc_subdir).unwrap();
1646 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1647
1648 let directives = vec![
1649 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1650 Directive::Document(Document {
1651 date: date(2024, 1, 15),
1652 account: "Assets:Bank".into(),
1653 path: doc_subdir.join(filename).display().to_string(),
1654 tags: vec![],
1655 links: vec![],
1656 meta: Default::default(),
1657 }),
1658 ];
1659
1660 let options = ValidationOptions::default()
1662 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1663 let errors = validate_with_options(&directives, options);
1664 assert!(
1665 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1666 "Absolute path should work even with wrong document_dirs: {errors:?}"
1667 );
1668 }
1669
1670 #[test]
1679 fn test_validate_document_parallel_batch_check() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let doc_subdir = dir.path().join("docs");
1682 std::fs::create_dir_all(&doc_subdir).unwrap();
1683
1684 let mut directives: Vec<Directive> =
1687 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1688 for i in 0..100 {
1689 let filename = format!("receipt_{i}.pdf");
1690 if i % 2 == 0 {
1691 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1692 }
1693 directives.push(Directive::Document(Document {
1694 date: date(2024, 1, 15),
1695 account: "Assets:Bank".into(),
1696 path: filename,
1697 tags: vec![],
1698 links: vec![],
1699 meta: Default::default(),
1700 }));
1701 }
1702
1703 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1704 let errors = validate_with_options(&directives, options);
1705
1706 let not_found_count = errors
1707 .iter()
1708 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1709 .count();
1710 assert_eq!(
1711 not_found_count, 50,
1712 "exactly 50 of 100 documents should error as not-found"
1713 );
1714
1715 let example = errors
1719 .iter()
1720 .find(|e| e.code == ErrorCode::DocumentNotFound)
1721 .expect("should have at least one not-found error");
1722 assert!(
1723 example
1724 .context
1725 .as_deref()
1726 .is_some_and(|c| c.contains("searched")),
1727 "error context should mention the searched dirs, got: {:?}",
1728 example.context
1729 );
1730 }
1731
1732 #[test]
1733 fn test_error_code_is_warning() {
1734 assert!(!ErrorCode::AccountNotOpen.is_warning());
1735 assert!(!ErrorCode::DocumentNotFound.is_warning());
1736 assert!(ErrorCode::FutureDate.is_warning());
1737 }
1738
1739 #[test]
1740 fn test_validate_pad_basic() {
1741 let directives = vec![
1742 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1743 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1744 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1745 Directive::Balance(Balance::new(
1746 date(2024, 1, 2),
1747 "Assets:Bank",
1748 Amount::new(dec!(1000.00), "USD"),
1749 )),
1750 ];
1751
1752 let errors = validate(&directives);
1753 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1755 }
1756
1757 #[test]
1758 fn test_validate_pad_with_existing_balance() {
1759 let directives = vec![
1760 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1761 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1762 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1763 Directive::Transaction(
1765 Transaction::new(date(2024, 1, 5), "Initial deposit")
1766 .with_synthesized_posting(Posting::new(
1767 "Assets:Bank",
1768 Amount::new(dec!(500.00), "USD"),
1769 ))
1770 .with_synthesized_posting(Posting::new(
1771 "Income:Salary",
1772 Amount::new(dec!(-500.00), "USD"),
1773 )),
1774 ),
1775 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1777 Directive::Balance(Balance::new(
1778 date(2024, 1, 15),
1779 "Assets:Bank",
1780 Amount::new(dec!(1000.00), "USD"), )),
1782 ];
1783
1784 let errors = validate(&directives);
1785 assert!(
1787 errors.is_empty(),
1788 "Pad should add missing amount: {errors:?}"
1789 );
1790 }
1791
1792 #[test]
1793 fn test_validate_pad_account_not_open() {
1794 let directives = vec![
1795 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1796 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1798 ];
1799
1800 let errors = validate(&directives);
1801 assert!(
1802 errors
1803 .iter()
1804 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1805 "Should error for pad on unopened account"
1806 );
1807 }
1808
1809 #[test]
1810 fn test_validate_pad_source_not_open() {
1811 let directives = vec![
1812 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1813 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1815 ];
1816
1817 let errors = validate(&directives);
1818 assert!(
1819 errors.iter().any(
1820 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1821 ),
1822 "Should error for pad with unopened source account"
1823 );
1824 }
1825
1826 #[test]
1827 fn test_validate_pad_negative_adjustment() {
1828 let directives = vec![
1830 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1831 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1832 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1833 Directive::Transaction(
1835 Transaction::new(date(2024, 1, 5), "Big deposit")
1836 .with_synthesized_posting(Posting::new(
1837 "Assets:Bank",
1838 Amount::new(dec!(2000.00), "USD"),
1839 ))
1840 .with_synthesized_posting(Posting::new(
1841 "Income:Salary",
1842 Amount::new(dec!(-2000.00), "USD"),
1843 )),
1844 ),
1845 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1847 Directive::Balance(Balance::new(
1848 date(2024, 1, 15),
1849 "Assets:Bank",
1850 Amount::new(dec!(1000.00), "USD"), )),
1852 ];
1853
1854 let errors = validate(&directives);
1855 assert!(
1856 errors.is_empty(),
1857 "Pad should handle negative adjustment: {errors:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_validate_insufficient_units() {
1863 use rustledger_core::CostSpec;
1864
1865 let cost_spec = CostSpec::empty()
1866 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1867 .with_currency("USD");
1868
1869 let directives = vec![
1870 Directive::Open(
1871 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1872 ),
1873 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1874 Directive::Transaction(
1876 Transaction::new(date(2024, 1, 15), "Buy")
1877 .with_synthesized_posting(
1878 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1879 .with_cost(cost_spec.clone()),
1880 )
1881 .with_synthesized_posting(Posting::new(
1882 "Assets:Cash",
1883 Amount::new(dec!(-1500), "USD"),
1884 )),
1885 ),
1886 Directive::Transaction(
1888 Transaction::new(date(2024, 6, 1), "Sell too many")
1889 .with_synthesized_posting(
1890 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1891 .with_cost(cost_spec),
1892 )
1893 .with_synthesized_posting(Posting::new(
1894 "Assets:Cash",
1895 Amount::new(dec!(2250), "USD"),
1896 )),
1897 ),
1898 ];
1899
1900 let errors = validate(&directives);
1901 assert!(
1902 errors
1903 .iter()
1904 .any(|e| e.code == ErrorCode::InsufficientUnits),
1905 "Should error for insufficient units: {errors:?}"
1906 );
1907 }
1908
1909 #[test]
1910 fn test_validate_no_matching_lot() {
1911 use rustledger_core::CostSpec;
1912
1913 let directives = vec![
1914 Directive::Open(
1915 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1916 ),
1917 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1918 Directive::Transaction(
1920 Transaction::new(date(2024, 1, 15), "Buy")
1921 .with_synthesized_posting(
1922 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1923 CostSpec::empty()
1924 .with_number(rustledger_core::CostNumber::PerUnit {
1925 value: dec!(150),
1926 })
1927 .with_currency("USD"),
1928 ),
1929 )
1930 .with_synthesized_posting(Posting::new(
1931 "Assets:Cash",
1932 Amount::new(dec!(-1500), "USD"),
1933 )),
1934 ),
1935 Directive::Transaction(
1937 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1938 .with_synthesized_posting(
1939 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1940 CostSpec::empty()
1941 .with_number(rustledger_core::CostNumber::PerUnit {
1942 value: dec!(160),
1943 })
1944 .with_currency("USD"),
1945 ),
1946 )
1947 .with_synthesized_posting(Posting::new(
1948 "Assets:Cash",
1949 Amount::new(dec!(800), "USD"),
1950 )),
1951 ),
1952 ];
1953
1954 let errors = validate(&directives);
1955 assert!(
1956 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1957 "Should error for no matching lot: {errors:?}"
1958 );
1959 }
1960
1961 #[test]
1962 fn test_validate_multiple_lot_match_uses_fifo() {
1963 use rustledger_core::CostSpec;
1966
1967 let cost_spec = CostSpec::empty()
1968 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1969 .with_currency("USD");
1970
1971 let directives = vec![
1972 Directive::Open(
1973 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1974 ),
1975 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1976 Directive::Transaction(
1978 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1979 .with_synthesized_posting(
1980 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1981 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1982 )
1983 .with_synthesized_posting(Posting::new(
1984 "Assets:Cash",
1985 Amount::new(dec!(-1500), "USD"),
1986 )),
1987 ),
1988 Directive::Transaction(
1990 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1991 .with_synthesized_posting(
1992 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1993 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1994 )
1995 .with_synthesized_posting(Posting::new(
1996 "Assets:Cash",
1997 Amount::new(dec!(-1500), "USD"),
1998 )),
1999 ),
2000 Directive::Transaction(
2002 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
2003 .with_synthesized_posting(
2004 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2005 .with_cost(cost_spec),
2006 )
2007 .with_synthesized_posting(Posting::new(
2008 "Assets:Cash",
2009 Amount::new(dec!(750), "USD"),
2010 )),
2011 ),
2012 ];
2013
2014 let errors = validate(&directives);
2015 let booking_errors: Vec<_> = errors
2017 .iter()
2018 .filter(|e| {
2019 matches!(
2020 e.code,
2021 ErrorCode::InsufficientUnits
2022 | ErrorCode::NoMatchingLot
2023 | ErrorCode::AmbiguousLotMatch
2024 )
2025 })
2026 .collect();
2027 assert!(
2028 booking_errors.is_empty(),
2029 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
2030 );
2031 }
2032
2033 #[test]
2034 fn test_validate_successful_booking() {
2035 use rustledger_core::CostSpec;
2036
2037 let cost_spec = CostSpec::empty()
2038 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2039 .with_currency("USD");
2040
2041 let directives = vec![
2042 Directive::Open(
2043 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2044 ),
2045 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2046 Directive::Transaction(
2048 Transaction::new(date(2024, 1, 15), "Buy")
2049 .with_synthesized_posting(
2050 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2051 .with_cost(cost_spec.clone()),
2052 )
2053 .with_synthesized_posting(Posting::new(
2054 "Assets:Cash",
2055 Amount::new(dec!(-1500), "USD"),
2056 )),
2057 ),
2058 Directive::Transaction(
2060 Transaction::new(date(2024, 6, 1), "Sell")
2061 .with_synthesized_posting(
2062 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2063 .with_cost(cost_spec),
2064 )
2065 .with_synthesized_posting(Posting::new(
2066 "Assets:Cash",
2067 Amount::new(dec!(750), "USD"),
2068 )),
2069 ),
2070 ];
2071
2072 let errors = validate(&directives);
2073 let booking_errors: Vec<_> = errors
2075 .iter()
2076 .filter(|e| {
2077 matches!(
2078 e.code,
2079 ErrorCode::InsufficientUnits
2080 | ErrorCode::NoMatchingLot
2081 | ErrorCode::AmbiguousLotMatch
2082 )
2083 })
2084 .collect();
2085 assert!(
2086 booking_errors.is_empty(),
2087 "Should have no booking errors: {booking_errors:?}"
2088 );
2089 }
2090
2091 #[test]
2092 fn test_validate_account_already_open() {
2093 let directives = vec![
2094 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2095 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2097
2098 let errors = validate(&directives);
2099 assert!(
2100 errors
2101 .iter()
2102 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2103 "Should error for duplicate open: {errors:?}"
2104 );
2105 }
2106
2107 #[test]
2108 fn test_validate_account_close_not_empty() {
2109 let directives = vec![
2110 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2111 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2112 Directive::Transaction(
2113 Transaction::new(date(2024, 1, 15), "Deposit")
2114 .with_synthesized_posting(Posting::new(
2115 "Assets:Bank",
2116 Amount::new(dec!(100.00), "USD"),
2117 ))
2118 .with_synthesized_posting(Posting::new(
2119 "Income:Salary",
2120 Amount::new(dec!(-100.00), "USD"),
2121 )),
2122 ),
2123 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2125
2126 let errors = validate(&directives);
2127 assert!(
2128 errors
2129 .iter()
2130 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2131 "Should warn for closing account with balance: {errors:?}"
2132 );
2133 }
2134
2135 #[test]
2136 fn test_validate_no_postings_allowed() {
2137 let directives = vec![
2140 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2141 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2142 ];
2143
2144 let errors = validate(&directives);
2145 assert!(
2146 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2147 "Should NOT error for transaction with no postings: {errors:?}"
2148 );
2149 }
2150
2151 #[test]
2152 fn test_validate_single_posting() {
2153 let directives = vec![
2154 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2155 Directive::Transaction(
2156 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2157 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2158 ),
2159 ),
2160 ];
2161
2162 let errors = validate(&directives);
2163 assert!(
2164 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2165 "Should warn for transaction with single posting: {errors:?}"
2166 );
2167 assert!(ErrorCode::SinglePosting.is_warning());
2169 }
2170
2171 #[test]
2172 fn test_validate_single_posting_zero_cost_no_warning() {
2173 let directives = vec![
2177 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2178 Directive::Transaction(
2179 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2180 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2181 rustledger_core::CostSpec::empty()
2182 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2183 .with_currency("USD"),
2184 ),
2185 ),
2186 ),
2187 ];
2188
2189 let errors = validate(&directives);
2190 assert!(
2191 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2192 "Should NOT warn for zero-cost single posting: {errors:?}"
2193 );
2194 }
2195
2196 #[test]
2197 fn test_validate_single_posting_nonzero_cost_still_warns() {
2198 let directives = vec![
2200 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2201 Directive::Transaction(
2202 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2203 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2204 rustledger_core::CostSpec::empty()
2205 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2206 .with_currency("USD"),
2207 ),
2208 ),
2209 ),
2210 ];
2211
2212 let errors = validate(&directives);
2213 assert!(
2214 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2215 "Should warn for single posting with non-zero cost: {errors:?}"
2216 );
2217 }
2218
2219 #[test]
2220 fn test_validate_pad_without_balance() {
2221 let directives = vec![
2222 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2223 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2224 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2225 ];
2227
2228 let errors = validate(&directives);
2229 assert!(
2230 errors
2231 .iter()
2232 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2233 "Should error for pad without subsequent balance: {errors:?}"
2234 );
2235 }
2236
2237 #[test]
2238 fn test_validate_multiple_pads_for_balance() {
2239 let directives = vec![
2240 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2241 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2242 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2243 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2245 date(2024, 1, 3),
2246 "Assets:Bank",
2247 Amount::new(dec!(1000.00), "USD"),
2248 )),
2249 ];
2250
2251 let errors = validate(&directives);
2252 assert!(
2253 errors
2254 .iter()
2255 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2256 "Should error for multiple pads before balance: {errors:?}"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2262 let directives = vec![
2268 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2269 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2270 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2272 Directive::Balance(Balance::new(
2273 date(2024, 1, 2),
2274 "Assets:Bank",
2275 Amount::new(dec!(100.00), "USD"),
2276 )),
2277 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2280 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2281 Directive::Balance(Balance::new(
2282 date(2024, 2, 3),
2283 "Assets:Bank",
2284 Amount::new(dec!(200.00), "USD"),
2285 )),
2286 ];
2287
2288 let errors = validate(&directives);
2289 let multi_pad_count = errors
2290 .iter()
2291 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2292 .count();
2293 assert_eq!(
2294 multi_pad_count, 1,
2295 "E2004 must fire exactly once on the second balance; got {errors:?}"
2296 );
2297 }
2298
2299 #[test]
2300 fn test_pad_serves_multi_currency_balances_on_same_day() {
2301 let directives = vec![
2308 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2309 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2310 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2311 Directive::Balance(Balance::new(
2313 date(2024, 1, 2),
2314 "Assets:Bank",
2315 Amount::new(dec!(100.00), "USD"),
2316 )),
2317 Directive::Balance(Balance::new(
2318 date(2024, 1, 2),
2319 "Assets:Bank",
2320 Amount::new(dec!(50.00), "EUR"),
2321 )),
2322 ];
2323
2324 let errors = validate(&directives);
2325 assert!(
2326 !errors
2327 .iter()
2328 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2329 "pad should serve both USD and EUR; got {errors:?}"
2330 );
2331 assert!(
2332 !errors
2333 .iter()
2334 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2335 "pad serves at least one balance; should not be E2003; got {errors:?}"
2336 );
2337 }
2338
2339 #[test]
2340 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2341 let directives = vec![
2346 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2347 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2348 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2349 Directive::Balance(Balance::new(
2350 date(2024, 1, 2),
2351 "Assets:Bank",
2352 Amount::new(dec!(100.00), "USD"),
2353 )),
2354 ];
2355
2356 let errors = validate(&directives);
2357 assert!(
2361 errors
2362 .iter()
2363 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2364 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2365 );
2366 assert!(
2368 errors
2369 .iter()
2370 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2371 "same-day pad never consumed; expected E2003; got {errors:?}"
2372 );
2373 }
2374
2375 #[test]
2376 fn test_future_pad_does_not_apply_to_earlier_balance() {
2377 let directives = vec![
2383 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2384 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2385 Directive::Balance(Balance::new(
2386 date(2024, 1, 2),
2387 "Assets:Bank",
2388 Amount::new(dec!(0.00), "USD"),
2389 )),
2390 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2391 ];
2392
2393 let errors = validate(&directives);
2394 assert!(
2397 !errors
2398 .iter()
2399 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2400 "future pad should not influence earlier balance; got {errors:?}"
2401 );
2402 assert!(
2404 errors
2405 .iter()
2406 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2407 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2408 );
2409 }
2410
2411 #[test]
2412 fn test_error_severity() {
2413 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2415 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2416 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2417
2418 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2420 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2421 assert_eq!(
2422 ErrorCode::AccountCloseNotEmpty.severity(),
2423 Severity::Warning
2424 );
2425
2426 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2428 }
2429
2430 #[test]
2431 fn test_validate_invalid_account_name() {
2432 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2434
2435 let errors = validate(&directives);
2436 assert!(
2437 errors
2438 .iter()
2439 .any(|e| e.code == ErrorCode::InvalidAccountName),
2440 "Should error for invalid account root: {errors:?}"
2441 );
2442 }
2443
2444 #[test]
2445 fn test_validate_account_lowercase_component() {
2446 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2448
2449 let errors = validate(&directives);
2450 assert!(
2451 errors
2452 .iter()
2453 .any(|e| e.code == ErrorCode::InvalidAccountName),
2454 "Should error for lowercase component: {errors:?}"
2455 );
2456 }
2457
2458 #[test]
2459 fn test_validate_valid_account_names() {
2460 let valid_names = [
2462 "Assets:Bank",
2463 "Assets:Bank:Checking",
2464 "Liabilities:CreditCard",
2465 "Equity:Opening-Balances",
2466 "Income:Salary2024",
2467 "Expenses:Food:Restaurant",
2468 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2474
2475 for name in valid_names {
2476 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2477
2478 let errors = validate(&directives);
2479 let name_errors: Vec<_> = errors
2480 .iter()
2481 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2482 .collect();
2483 assert!(
2484 name_errors.is_empty(),
2485 "Should accept valid account name '{name}': {name_errors:?}"
2486 );
2487 }
2488 }
2489
2490 #[test]
2495 fn test_e2002_balance_exceeds_explicit_tolerance() {
2496 let directives = vec![
2499 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2500 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2501 Directive::Transaction(
2502 Transaction::new(date(2024, 1, 15), "Deposit")
2503 .with_synthesized_posting(Posting::new(
2504 "Assets:Bank",
2505 Amount::new(dec!(1000.00), "USD"),
2506 ))
2507 .with_synthesized_posting(Posting::new(
2508 "Income:Salary",
2509 Amount::new(dec!(-1000.00), "USD"),
2510 )),
2511 ),
2512 Directive::Balance(
2515 Balance::new(
2516 date(2024, 1, 16),
2517 "Assets:Bank",
2518 Amount::new(dec!(999.00), "USD"),
2519 )
2520 .with_tolerance(dec!(0.01)),
2521 ),
2522 ];
2523
2524 let errors = validate(&directives);
2525
2526 assert!(
2527 errors
2528 .iter()
2529 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2530 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2531 );
2532 }
2533
2534 #[test]
2535 fn test_e2002_balance_within_explicit_tolerance_passes() {
2536 let directives = vec![
2538 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2539 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2540 Directive::Transaction(
2541 Transaction::new(date(2024, 1, 15), "Deposit")
2542 .with_synthesized_posting(Posting::new(
2543 "Assets:Bank",
2544 Amount::new(dec!(1000.00), "USD"),
2545 ))
2546 .with_synthesized_posting(Posting::new(
2547 "Income:Salary",
2548 Amount::new(dec!(-1000.00), "USD"),
2549 )),
2550 ),
2551 Directive::Balance(
2553 Balance::new(
2554 date(2024, 1, 16),
2555 "Assets:Bank",
2556 Amount::new(dec!(999.00), "USD"),
2557 )
2558 .with_tolerance(dec!(5.00)),
2559 ),
2560 ];
2561
2562 let errors = validate(&directives);
2563
2564 assert!(
2565 !errors
2566 .iter()
2567 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2568 || e.code == ErrorCode::BalanceAssertionFailed),
2569 "Expected no balance errors, got: {errors:?}"
2570 );
2571 }
2572
2573 #[test]
2574 fn test_e5001_undeclared_currency() {
2575 use rustledger_core::Commodity;
2578
2579 let directives = vec![
2580 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2581 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2582 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2583 Directive::Transaction(
2584 Transaction::new(date(2024, 1, 15), "Lunch")
2585 .with_synthesized_posting(Posting::new(
2586 "Expenses:Food",
2587 Amount::new(dec!(20.00), "EUR"), ))
2589 .with_synthesized_posting(Posting::new(
2590 "Assets:Bank",
2591 Amount::new(dec!(-20.00), "EUR"),
2592 )),
2593 ),
2594 ];
2595
2596 let options = ValidationOptions::default().with_require_commodities(true);
2597 let errors = validate_with_options(&directives, options);
2598
2599 assert!(
2600 errors
2601 .iter()
2602 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2603 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2604 );
2605 }
2606
2607 #[test]
2608 fn test_e5001_declared_currency_passes() {
2609 use rustledger_core::Commodity;
2611
2612 let directives = vec![
2613 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2614 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2615 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2616 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2617 Directive::Transaction(
2618 Transaction::new(date(2024, 1, 15), "Lunch")
2619 .with_synthesized_posting(Posting::new(
2620 "Expenses:Food",
2621 Amount::new(dec!(20.00), "EUR"),
2622 ))
2623 .with_synthesized_posting(Posting::new(
2624 "Assets:Bank",
2625 Amount::new(dec!(-20.00), "EUR"),
2626 )),
2627 ),
2628 ];
2629
2630 let options = ValidationOptions::default().with_require_commodities(true);
2631 let errors = validate_with_options(&directives, options);
2632
2633 assert!(
2634 !errors
2635 .iter()
2636 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2637 "Expected no E5001 errors, got: {errors:?}"
2638 );
2639 }
2640
2641 #[test]
2642 fn test_e5001_not_raised_without_require_commodities() {
2643 let directives = vec![
2645 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2646 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2647 Directive::Transaction(
2648 Transaction::new(date(2024, 1, 15), "Lunch")
2649 .with_synthesized_posting(Posting::new(
2650 "Expenses:Food",
2651 Amount::new(dec!(20.00), "XYZ"), ))
2653 .with_synthesized_posting(Posting::new(
2654 "Assets:Bank",
2655 Amount::new(dec!(-20.00), "XYZ"),
2656 )),
2657 ),
2658 ];
2659
2660 let errors = validate(&directives);
2661
2662 assert!(
2663 !errors
2664 .iter()
2665 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2666 "Should not raise E5001 without require_commodities, got: {errors:?}"
2667 );
2668 }
2669
2670 #[test]
2671 fn test_e3002_multiple_missing_amounts() {
2672 let directives = vec![
2674 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2675 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2676 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2677 Directive::Transaction(
2678 Transaction::new(date(2024, 1, 15), "Lunch")
2679 .with_synthesized_posting(Posting::new(
2680 "Assets:Bank",
2681 Amount::new(dec!(-50.00), "USD"),
2682 ))
2683 .with_synthesized_posting(Posting {
2685 account: "Expenses:Food".into(),
2686 units: None,
2687 cost: None,
2688 price: None,
2689 flag: None,
2690 meta: Default::default(),
2691 comments: vec![],
2692 trailing_comments: vec![],
2693 })
2694 .with_synthesized_posting(Posting {
2695 account: "Expenses:Drinks".into(),
2696 units: None,
2697 cost: None,
2698 price: None,
2699 flag: None,
2700 meta: Default::default(),
2701 comments: vec![],
2702 trailing_comments: vec![],
2703 }),
2704 ),
2705 ];
2706
2707 let errors = validate(&directives);
2708
2709 assert!(
2710 errors
2711 .iter()
2712 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2713 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2714 );
2715 }
2716
2717 #[test]
2718 fn test_e3002_single_missing_amount_ok() {
2719 let directives = vec![
2721 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2722 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2723 Directive::Transaction(
2724 Transaction::new(date(2024, 1, 15), "Lunch")
2725 .with_synthesized_posting(Posting::new(
2726 "Assets:Bank",
2727 Amount::new(dec!(-50.00), "USD"),
2728 ))
2729 .with_synthesized_posting(Posting {
2730 account: "Expenses:Food".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 "Should not raise E3002 with single missing amount, got: {errors:?}"
2749 );
2750 }
2751
2752 #[test]
2753 fn test_e7001_unknown_option() {
2754 let state = LedgerState::new();
2756 let mut errors = Vec::new();
2757
2758 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2759
2760 assert_eq!(errors.len(), 1);
2761 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2762 assert!(errors[0].message.contains("bogus_option"));
2763 }
2764
2765 #[test]
2766 fn test_e7002_invalid_option_value() {
2767 let state = LedgerState::new();
2768 let mut errors = Vec::new();
2769
2770 state.import_option_warnings(
2771 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2772 &mut errors,
2773 );
2774
2775 assert_eq!(errors.len(), 1);
2776 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2777 }
2778
2779 #[test]
2780 fn test_e7003_duplicate_option() {
2781 let state = LedgerState::new();
2782 let mut errors = Vec::new();
2783
2784 state.import_option_warnings(
2785 &[("E7003", "Option \"title\" can only be specified once")],
2786 &mut errors,
2787 );
2788
2789 assert_eq!(errors.len(), 1);
2790 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2791 }
2792
2793 fn commodity_with_precision(value: MetaValue) -> Directive {
2796 let mut meta = rustledger_core::Metadata::default();
2797 meta.insert("precision".into(), value);
2798 Directive::Commodity(
2799 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2800 )
2801 }
2802
2803 #[test]
2804 fn precision_meta_valid_integer_emits_no_warning() {
2805 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2806 let errors = validate(&directives);
2807 assert!(
2808 errors
2809 .iter()
2810 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2811 "valid precision must not produce a warning, got: {errors:?}"
2812 );
2813 }
2814
2815 #[test]
2816 fn precision_meta_zero_is_valid() {
2817 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2818 let errors = validate(&directives);
2819 assert!(
2820 errors
2821 .iter()
2822 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2823 );
2824 }
2825
2826 #[test]
2827 fn precision_meta_negative_emits_e5003() {
2828 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2829 let errors = validate(&directives);
2830 let warnings: Vec<_> = errors
2831 .iter()
2832 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2833 .collect();
2834 assert_eq!(warnings.len(), 1, "expected one E5003");
2835 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2836 assert!(warnings[0].message.contains("non-negative"));
2837 }
2838
2839 #[test]
2840 fn precision_meta_non_integer_emits_e5003() {
2841 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2842 let errors = validate(&directives);
2843 let warnings: Vec<_> = errors
2844 .iter()
2845 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2846 .collect();
2847 assert_eq!(warnings.len(), 1);
2848 assert!(warnings[0].message.contains("integer"));
2849 }
2850
2851 #[test]
2852 fn precision_meta_string_value_emits_e5003() {
2853 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2854 let errors = validate(&directives);
2855 let warnings: Vec<_> = errors
2856 .iter()
2857 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2858 .collect();
2859 assert_eq!(warnings.len(), 1);
2860 assert!(warnings[0].message.contains("string"));
2861 }
2862
2863 #[test]
2864 fn precision_meta_out_of_u32_range_emits_e5003() {
2865 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2867 8589934592
2868 )))];
2869 let errors = validate(&directives);
2870 let warnings: Vec<_> = errors
2871 .iter()
2872 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2873 .collect();
2874 assert_eq!(warnings.len(), 1);
2875 assert!(warnings[0].message.contains("exceeds"));
2876 }
2877
2878 #[test]
2879 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2880 let directives = vec![
2885 commodity_with_precision(MetaValue::Number(dec!(2))),
2886 commodity_with_precision(MetaValue::Number(dec!(-1))),
2887 ];
2888 let warnings: Vec<_> = validate(&directives)
2889 .into_iter()
2890 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2891 .collect();
2892 assert_eq!(
2893 warnings.len(),
2894 1,
2895 "exactly one E5003 expected (only the invalid declaration)"
2896 );
2897 assert!(warnings[0].message.contains("non-negative"));
2898 }
2899
2900 #[test]
2901 fn precision_meta_e5003_is_warning_severity() {
2902 assert_eq!(
2906 ErrorCode::InvalidPrecisionMetadata.severity(),
2907 Severity::Warning
2908 );
2909 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2910 }
2911
2912 #[test]
2920 fn test_validate_early_emits_e1001_on_elided_posting() {
2921 let directives = vec![
2922 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2923 Directive::Transaction(
2924 Transaction::new(date(2024, 1, 15), "Zero to unopened")
2925 .with_synthesized_posting(Posting::new(
2926 "Assets:Bank",
2927 Amount::new(dec!(0.00), "USD"),
2928 ))
2929 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
2930 ),
2931 ];
2932
2933 let session = ValidationSession::new(ValidationOptions::default());
2934 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
2935
2936 assert!(
2937 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
2938 && e.to_string().contains("Expenses:NeverOpened")),
2939 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
2940 );
2941 }
2942
2943 #[test]
2947 fn test_validate_late_does_not_duplicate_e1001() {
2948 let directives = vec![
2949 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2950 Directive::Transaction(
2951 Transaction::new(date(2024, 1, 15), "To unopened")
2952 .with_synthesized_posting(Posting::new(
2953 "Assets:Bank",
2954 Amount::new(dec!(100), "USD"),
2955 ))
2956 .with_synthesized_posting(Posting::new(
2957 "Expenses:NeverOpened",
2958 Amount::new(dec!(-100), "USD"),
2959 )),
2960 ),
2961 ];
2962
2963 let session = ValidationSession::new(ValidationOptions::default());
2964 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
2965 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
2966
2967 let early_e1001 = early
2968 .iter()
2969 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2970 .count();
2971 let late_e1001 = late
2972 .iter()
2973 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2974 .count();
2975
2976 assert_eq!(
2977 early_e1001, 0,
2978 "explicit posting: early phase defers E1001 to late; got: {early:?}"
2979 );
2980 assert_eq!(
2981 late_e1001, 1,
2982 "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
2983 );
2984 }
2985
2986 #[test]
2992 fn test_validate_chained_matches_explicit_phases() {
2993 let directives = vec![
2997 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2998 Directive::Transaction(
2999 Transaction::new(date(2024, 1, 15), "Mixed")
3000 .with_synthesized_posting(Posting::new(
3001 "Assets:Bank",
3002 Amount::new(dec!(50), "USD"),
3003 ))
3004 .with_synthesized_posting(Posting::new(
3005 "Income:Salary",
3006 Amount::new(dec!(-50), "USD"),
3007 )),
3008 ),
3009 Directive::Balance(Balance::new(
3010 date(2024, 1, 16),
3011 "Assets:Bank",
3012 Amount::new(dec!(50), "USD"),
3013 )),
3014 ];
3015
3016 let chained = validate(&directives);
3018
3019 let session = ValidationSession::new(ValidationOptions::default());
3021 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3022 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3023 explicit.extend(late_errs);
3024 explicit.extend(session.finalize());
3025
3026 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3030 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3031 assert_eq!(
3032 chained_strs, explicit_strs,
3033 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3034 );
3035 }
3036
3037 #[test]
3038 fn test_phase_order_early_then_late_then_finalize() {
3039 let directives = vec![
3046 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3047 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3048 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3049 Directive::Transaction(
3051 Transaction::new(date(2024, 1, 5), "early")
3052 .with_synthesized_posting(Posting::new(
3053 "Assets:Bank",
3054 Amount::new(dec!(100), "USD"),
3055 ))
3056 .with_synthesized_posting(Posting::new(
3057 "Income:Salary",
3058 Amount::new(dec!(-100), "USD"),
3059 )),
3060 ),
3061 Directive::Pad(Pad::new(
3063 date(2024, 1, 10),
3064 "Assets:Other",
3065 "Equity:Opening",
3066 )),
3067 Directive::Balance(Balance::new(
3069 date(2024, 2, 1),
3070 "Assets:Bank",
3071 Amount::new(dec!(999), "USD"),
3072 )),
3073 ];
3074
3075 let errors = validate(&directives);
3076 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3077
3078 let early_pos = codes
3079 .iter()
3080 .position(|c| *c == ErrorCode::AccountNotOpen)
3081 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3082 let late_pos = codes
3083 .iter()
3084 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3085 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3086 let finalize_pos = codes
3087 .iter()
3088 .position(|c| *c == ErrorCode::PadWithoutBalance)
3089 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3090
3091 assert!(
3092 early_pos < late_pos,
3093 "early-phase errors must precede late-phase; got {codes:?}"
3094 );
3095 assert!(
3096 late_pos < finalize_pos,
3097 "late-phase errors must precede finalize; got {codes:?}"
3098 );
3099 }
3100
3101 #[test]
3102 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3103 let directives = vec![
3110 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3111 Directive::Transaction(
3114 Transaction::new(date(2024, 1, 10), "leave residue")
3115 .with_synthesized_posting(Posting::new(
3116 "Assets:Bank",
3117 Amount::new(dec!(50), "USD"),
3118 ))
3119 .with_synthesized_posting(Posting::new(
3120 "Equity:Opening",
3121 Amount::new(dec!(-50), "USD"),
3122 )),
3123 ),
3124 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3125 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3126 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3127 ];
3128
3129 let errors = validate(&directives);
3130 let close_not_empty_count = errors
3131 .iter()
3132 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3133 .count();
3134 assert_eq!(
3135 close_not_empty_count, 1,
3136 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3137 );
3138 let account_closed_count = errors
3140 .iter()
3141 .filter(|e| e.code == ErrorCode::AccountClosed)
3142 .count();
3143 assert_eq!(
3144 account_closed_count, 1,
3145 "duplicate close should still report AccountClosed once; got {errors:?}"
3146 );
3147 }
3148
3149 #[test]
3175 fn typestate_pins_phase_ordering_at_compile_time() {
3176 fn _expect_pending_returns_early(
3188 s: ValidationSession<Pending>,
3189 ) -> ValidationSession<EarlyDone> {
3190 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3191 s
3192 }
3193 fn _expect_early_returns_late(
3194 s: ValidationSession<EarlyDone>,
3195 ) -> ValidationSession<LateDone> {
3196 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3197 s
3198 }
3199 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3200 s.finalize()
3201 }
3202 }
3203}