1#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47mod error;
48mod validators;
49
50pub use error::{ErrorCode, Severity, ValidationError};
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 validate_balance_early, validate_balance_late, validate_close, validate_close_late,
92 validate_document, validate_note, validate_open, validate_pad, validate_transaction_early,
93 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::{BookingMethod, Commodity, Directive, Inventory};
110use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
111
112#[derive(Debug, Clone)]
114struct AccountState {
115 opened: NaiveDate,
117 closed: Option<NaiveDate>,
119 currencies: FxHashSet<rustledger_core::Currency>,
121 booking: BookingMethod,
124}
125
126#[non_exhaustive]
128#[derive(Debug, Clone)]
129pub struct ValidationOptions {
130 pub require_commodities: bool,
132 pub check_documents: bool,
134 pub warn_future_dates: bool,
136 pub document_base: Option<std::path::PathBuf>,
138 pub document_dirs: Vec<std::path::PathBuf>,
142 pub account_types: Vec<String>,
145 pub infer_tolerance_from_cost: bool,
148 pub tolerance_multiplier: Decimal,
151 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
154 pub default_booking_method: BookingMethod,
164}
165
166impl Default for ValidationOptions {
167 fn default() -> Self {
168 Self {
169 require_commodities: false,
170 check_documents: true, warn_future_dates: false,
172 document_base: None,
173 document_dirs: Vec::new(),
174 account_types: vec![
175 "Assets".to_string(),
176 "Liabilities".to_string(),
177 "Equity".to_string(),
178 "Income".to_string(),
179 "Expenses".to_string(),
180 ],
181 infer_tolerance_from_cost: false,
183 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
185 default_booking_method: BookingMethod::default(),
186 }
187 }
188}
189
190impl ValidationOptions {
191 #[must_use]
193 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
194 self.account_types = types;
195 self
196 }
197
198 #[must_use]
200 pub const fn with_require_commodities(mut self, require: bool) -> Self {
201 self.require_commodities = require;
202 self
203 }
204
205 #[must_use]
207 pub const fn with_check_documents(mut self, check: bool) -> Self {
208 self.check_documents = check;
209 self
210 }
211
212 #[must_use]
214 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
215 self.warn_future_dates = warn;
216 self
217 }
218
219 #[must_use]
221 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
222 self.document_dirs = dirs;
223 self
224 }
225
226 #[must_use]
228 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
229 self.infer_tolerance_from_cost = infer;
230 self
231 }
232
233 #[must_use]
235 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
236 self.tolerance_multiplier = multiplier;
237 self
238 }
239
240 #[must_use]
242 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
243 self.inferred_tolerance_default = defaults;
244 self
245 }
246
247 #[must_use]
252 pub const fn with_default_booking_method(mut self, method: BookingMethod) -> Self {
253 self.default_booking_method = method;
254 self
255 }
256}
257
258#[derive(Debug, Clone)]
260struct PendingPad {
261 source_account: rustledger_core::Account,
263 date: NaiveDate,
265 padded_currencies: FxHashSet<rustledger_core::Currency>,
272}
273
274#[derive(Debug, Default)]
276pub struct LedgerState {
277 accounts: FxHashMap<rustledger_core::Account, AccountState>,
279 inventories: FxHashMap<rustledger_core::Account, Inventory>,
281 commodities: FxHashSet<rustledger_core::Currency>,
283 pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
285 options: ValidationOptions,
287 last_date: Option<NaiveDate>,
289 pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
300}
301
302impl LedgerState {
303 #[must_use]
305 pub fn new() -> Self {
306 Self::default()
307 }
308
309 #[must_use]
311 pub fn with_options(options: ValidationOptions) -> Self {
312 Self {
313 options,
314 ..Default::default()
315 }
316 }
317
318 pub const fn set_require_commodities(&mut self, require: bool) {
320 self.options.require_commodities = require;
321 }
322
323 pub const fn set_check_documents(&mut self, check: bool) {
325 self.options.check_documents = check;
326 }
327
328 pub const fn set_warn_future_dates(&mut self, warn: bool) {
330 self.options.warn_future_dates = warn;
331 }
332
333 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
335 self.options.document_base = Some(base.into());
336 }
337
338 #[must_use]
340 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
341 self.inventories.get(account)
342 }
343
344 pub fn accounts(&self) -> impl Iterator<Item = &str> {
346 self.accounts.keys().map(rustledger_core::Account::as_str)
347 }
348
349 pub fn import_option_warnings(
357 &self,
358 warnings: &[(&str, &str)],
359 errors: &mut Vec<ValidationError>,
360 ) {
361 for &(code, message) in warnings {
362 let error_code = match code {
363 "E7001" => ErrorCode::UnknownOption,
364 "E7002" => ErrorCode::InvalidOptionValue,
365 "E7003" => ErrorCode::DuplicateOption,
366 _ => continue,
367 };
368 errors.push(ValidationError::new(
369 error_code,
370 message.to_string(),
371 NaiveDate::default(),
373 ));
374 }
375 }
376}
377
378trait ValidatableDirective: Sync {
387 fn directive(&self) -> &Directive;
388 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
392}
393
394impl ValidatableDirective for Directive {
395 fn directive(&self) -> &Directive {
396 self
397 }
398 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
399 None
400 }
401}
402
403impl ValidatableDirective for Spanned<Directive> {
404 fn directive(&self) -> &Directive {
405 &self.value
406 }
407 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
408 Some((self.span, self.file_id))
409 }
410}
411
412fn validate_phase_inner<D: ValidatableDirective>(
423 directives: &[D],
424 state: &mut LedgerState,
425 phase: Phase,
426 today: NaiveDate,
427) -> Vec<ValidationError> {
428 let document_exists_cache = if phase == Phase::Early {
431 build_document_exists_cache(directives, &state.options)
432 } else {
433 FxHashMap::default()
434 };
435
436 if phase == Phase::Early {
440 state.last_date = None;
441 }
442
443 let mut errors = Vec::new();
444
445 let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
450 sorted.extend(directives.iter());
451 let sort_fn = |a: &&D, b: &&D| {
452 let ad = a.directive();
453 let bd = b.directive();
454 ad.date()
455 .cmp(&bd.date())
456 .then_with(|| ad.priority().cmp(&bd.priority()))
457 .then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
458 };
459 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
460 sorted.par_sort_by(sort_fn);
461 } else {
462 sorted.sort_by(sort_fn);
463 }
464
465 for d in sorted {
466 let directive = d.directive();
467 let date = directive.date();
468
469 let error_count_before = errors.len();
476
477 if phase == Phase::Early {
481 if let Some(last) = state.last_date
482 && date < last
483 {
484 errors.push(ValidationError::new(
485 ErrorCode::DateOutOfOrder,
486 format!("Directive date {date} is before previous directive {last}"),
487 date,
488 ));
489 }
490 state.last_date = Some(date);
491
492 if state.options.warn_future_dates && date > today {
493 errors.push(ValidationError::new(
494 ErrorCode::FutureDate,
495 format!("Entry dated in the future: {date}"),
496 date,
497 ));
498 }
499 }
500
501 match (phase, directive) {
502 (Phase::Early, Directive::Open(open)) => {
504 validate_open(state, open, &mut errors);
505 }
506 (Phase::Early, Directive::Close(close)) => {
507 validate_close(state, close, &mut errors);
508 }
509 (Phase::Late, Directive::Close(close)) => {
510 validate_close_late(state, close, &mut errors);
511 }
512 (Phase::Early, Directive::Commodity(comm)) => {
513 state.commodities.insert(comm.currency.clone());
514 validate_commodity_precision_meta(comm, &mut errors);
515 }
516 (Phase::Early, Directive::Pad(pad)) => {
517 validate_pad(state, pad, &mut errors);
518 }
519 (Phase::Early, Directive::Document(doc)) => {
520 validate_document(state, doc, &document_exists_cache, &mut errors);
521 }
522 (Phase::Early, Directive::Note(note)) => {
523 validate_note(state, note, &mut errors);
524 }
525 (Phase::Early, Directive::Transaction(txn)) => {
527 validate_transaction_early(state, txn, &mut errors);
528 }
529 (Phase::Late, Directive::Transaction(txn)) => {
530 validate_transaction_late(state, txn, &mut errors);
531 }
532 (Phase::Early, Directive::Balance(bal)) => {
533 validate_balance_early(state, bal, &mut errors);
534 }
535 (Phase::Late, Directive::Balance(bal)) => {
536 validate_balance_late(state, bal, &mut errors);
537 }
538 _ => {}
540 }
541
542 if let Some((span, file_id)) = d.span_info() {
549 for error in errors.iter_mut().skip(error_count_before) {
550 if error.span.is_none() {
551 error.span = Some(span);
552 error.file_id = Some(file_id);
553 }
554 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
555 error.note = Some(
556 "directive was synthesized by a plugin (no source location); \
557 check your `plugin \"…\"` declarations for the responsible plugin"
558 .to_string(),
559 );
560 }
561 }
562 }
563 }
564
565 errors
566}
567
568fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
572 let mut errors = Vec::new();
573 for (target_account, pads) in &state.pending_pads {
574 for pad in pads {
575 if pad.padded_currencies.is_empty() {
576 errors.push(
577 ValidationError::new(
578 ErrorCode::PadWithoutBalance,
579 "Unused Pad entry".to_string(),
580 pad.date,
581 )
582 .with_context(format!(
583 " {} pad {} {}",
584 pad.date, target_account, pad.source_account
585 )),
586 );
587 }
588 }
589 }
590 errors
591}
592
593fn build_document_exists_cache<D: ValidatableDirective>(
615 directives: &[D],
616 options: &ValidationOptions,
617) -> FxHashMap<String, bool> {
618 if !options.check_documents {
619 return FxHashMap::default();
620 }
621
622 let mut paths: FxHashSet<&str> = FxHashSet::default();
627 for d in directives {
628 if let Directive::Document(doc) = d.directive() {
629 paths.insert(doc.path.as_str());
630 }
631 }
632 let paths: Vec<&str> = paths.into_iter().collect();
633
634 let resolve = |s: &str| -> (String, bool) {
640 let doc_path = std::path::Path::new(s);
641 let found = if doc_path.is_absolute() {
642 doc_path.exists()
643 } else if let Some(base) = &options.document_base {
644 base.join(doc_path).exists()
645 } else if !options.document_dirs.is_empty() {
646 options
647 .document_dirs
648 .iter()
649 .any(|dir| dir.join(doc_path).exists())
650 } else {
651 doc_path.exists()
652 };
653 (s.to_string(), found)
654 };
655
656 if paths.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
657 paths.into_par_iter().map(resolve).collect()
658 } else {
659 paths.into_iter().map(resolve).collect()
660 }
661}
662
663pub mod phase {
708 mod sealed {
709 pub trait Sealed {}
710 }
711
712 pub trait SessionPhase: sealed::Sealed {}
715
716 macro_rules! define_phase {
717 ($name:ident, $doc:expr) => {
718 #[doc = $doc]
719 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
720 pub struct $name;
721 impl sealed::Sealed for $name {}
722 impl SessionPhase for $name {}
723 };
724 }
725
726 define_phase!(
727 Pending,
728 "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
729 );
730 define_phase!(
731 EarlyDone,
732 "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
733 );
734 define_phase!(
735 LateDone,
736 "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
737 );
738}
739
740pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
741
742pub struct ValidationSession<P: SessionPhase = Pending> {
815 state: LedgerState,
816 _phase: std::marker::PhantomData<P>,
817}
818
819impl ValidationSession<Pending> {
820 #[must_use]
825 pub fn new(options: ValidationOptions) -> Self {
826 Self {
827 state: LedgerState::with_options(options),
828 _phase: std::marker::PhantomData,
829 }
830 }
831
832 #[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"]
843 pub fn run_early(
844 self,
845 directives: &[Directive],
846 today: NaiveDate,
847 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
848 self.run_phase_internal(directives, Phase::Early, today)
849 }
850
851 #[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"]
855 pub fn run_early_spanned(
856 self,
857 directives: &[Spanned<Directive>],
858 today: NaiveDate,
859 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
860 self.run_phase_internal(directives, Phase::Early, today)
861 }
862
863 fn run_phase_internal<D: ValidatableDirective>(
871 mut self,
872 directives: &[D],
873 phase: Phase,
874 today: NaiveDate,
875 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
876 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
877 (
878 ValidationSession {
879 state: self.state,
880 _phase: std::marker::PhantomData,
881 },
882 errors,
883 )
884 }
885}
886
887impl ValidationSession<EarlyDone> {
888 #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
898 pub fn run_late(
899 self,
900 directives: &[Directive],
901 today: NaiveDate,
902 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
903 self.run_phase_internal(directives, Phase::Late, today)
904 }
905
906 #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
910 pub fn run_late_spanned(
911 self,
912 directives: &[Spanned<Directive>],
913 today: NaiveDate,
914 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
915 self.run_phase_internal(directives, Phase::Late, today)
916 }
917
918 fn run_phase_internal<D: ValidatableDirective>(
922 mut self,
923 directives: &[D],
924 phase: Phase,
925 today: NaiveDate,
926 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
927 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
928 (
929 ValidationSession {
930 state: self.state,
931 _phase: std::marker::PhantomData,
932 },
933 errors,
934 )
935 }
936}
937
938impl ValidationSession<LateDone> {
939 #[must_use]
943 pub fn finalize(self) -> Vec<ValidationError> {
944 check_unused_pads(&self.state)
945 }
946}
947
948fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
954 let Some(value) = comm.meta.get("precision") else {
955 return;
956 };
957 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
958 errors.push(ValidationError::new(
959 ErrorCode::InvalidPrecisionMetadata,
960 format!(
961 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
962 comm.currency
963 ),
964 comm.date,
965 ));
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use rust_decimal_macros::dec;
973 use rustledger_core::{
974 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
975 };
976
977 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
978 rustledger_core::naive_date(year, month, day).unwrap()
979 }
980
981 fn test_today() -> NaiveDate {
985 date(2030, 1, 1)
986 }
987
988 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
993 validate_with_options(directives, ValidationOptions::default())
994 }
995
996 fn validate_with_options(
999 directives: &[Directive],
1000 options: ValidationOptions,
1001 ) -> Vec<ValidationError> {
1002 validate_with_today(directives, options, test_today())
1003 }
1004
1005 fn validate_with_today(
1009 directives: &[Directive],
1010 options: ValidationOptions,
1011 today: NaiveDate,
1012 ) -> Vec<ValidationError> {
1013 let session = ValidationSession::new(options);
1014 let (session, mut errors) = session.run_early(directives, today);
1015 let (session, late_errors) = session.run_late(directives, today);
1016 errors.extend(late_errors);
1017 errors.extend(session.finalize());
1018 errors
1019 }
1020
1021 #[test]
1022 fn test_validate_account_lifecycle() {
1023 let directives = vec![
1024 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1025 Directive::Transaction(
1026 Transaction::new(date(2024, 1, 15), "Test")
1027 .with_synthesized_posting(Posting::new(
1028 "Assets:Bank",
1029 Amount::new(dec!(100), "USD"),
1030 ))
1031 .with_synthesized_posting(Posting::new(
1032 "Income:Salary",
1033 Amount::new(dec!(-100), "USD"),
1034 )),
1035 ),
1036 ];
1037
1038 let errors = validate(&directives);
1039
1040 assert!(errors
1042 .iter()
1043 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1044 }
1045
1046 #[test]
1047 fn test_validate_account_used_before_open() {
1048 let directives = vec![
1049 Directive::Transaction(
1050 Transaction::new(date(2024, 1, 1), "Test")
1051 .with_synthesized_posting(Posting::new(
1052 "Assets:Bank",
1053 Amount::new(dec!(100), "USD"),
1054 ))
1055 .with_synthesized_posting(Posting::new(
1056 "Income:Salary",
1057 Amount::new(dec!(-100), "USD"),
1058 )),
1059 ),
1060 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1061 ];
1062
1063 let errors = validate(&directives);
1064
1065 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1066 }
1067
1068 #[test]
1069 fn test_validate_account_used_after_close() {
1070 let directives = vec![
1071 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1072 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1073 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1074 Directive::Transaction(
1075 Transaction::new(date(2024, 7, 1), "Test")
1076 .with_synthesized_posting(Posting::new(
1077 "Assets:Bank",
1078 Amount::new(dec!(-50), "USD"),
1079 ))
1080 .with_synthesized_posting(Posting::new(
1081 "Expenses:Food",
1082 Amount::new(dec!(50), "USD"),
1083 )),
1084 ),
1085 ];
1086
1087 let errors = validate(&directives);
1088
1089 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1090 }
1091
1092 #[test]
1093 fn test_validate_balance_assertion() {
1094 let directives = vec![
1095 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1096 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1097 Directive::Transaction(
1098 Transaction::new(date(2024, 1, 15), "Deposit")
1099 .with_synthesized_posting(Posting::new(
1100 "Assets:Bank",
1101 Amount::new(dec!(1000.00), "USD"),
1102 ))
1103 .with_synthesized_posting(Posting::new(
1104 "Income:Salary",
1105 Amount::new(dec!(-1000.00), "USD"),
1106 )),
1107 ),
1108 Directive::Balance(Balance::new(
1109 date(2024, 1, 16),
1110 "Assets:Bank",
1111 Amount::new(dec!(1000.00), "USD"),
1112 )),
1113 ];
1114
1115 let errors = validate(&directives);
1116 assert!(errors.is_empty(), "{errors:?}");
1117 }
1118
1119 #[test]
1120 fn test_validate_balance_assertion_failed() {
1121 let directives = vec![
1122 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1123 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1124 Directive::Transaction(
1125 Transaction::new(date(2024, 1, 15), "Deposit")
1126 .with_synthesized_posting(Posting::new(
1127 "Assets:Bank",
1128 Amount::new(dec!(1000.00), "USD"),
1129 ))
1130 .with_synthesized_posting(Posting::new(
1131 "Income:Salary",
1132 Amount::new(dec!(-1000.00), "USD"),
1133 )),
1134 ),
1135 Directive::Balance(Balance::new(
1136 date(2024, 1, 16),
1137 "Assets:Bank",
1138 Amount::new(dec!(500.00), "USD"), )),
1140 ];
1141
1142 let errors = validate(&directives);
1143 assert!(
1144 errors
1145 .iter()
1146 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1147 );
1148 }
1149
1150 #[test]
1156 fn test_validate_balance_assertion_within_tolerance() {
1157 let directives = vec![
1162 Directive::Open(
1163 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1164 ),
1165 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1166 Directive::Transaction(
1167 Transaction::new(date(2024, 1, 15), "Deposit")
1168 .with_synthesized_posting(Posting::new(
1169 "Assets:Bank",
1170 Amount::new(dec!(70.538), "ABC"), ))
1172 .with_synthesized_posting(Posting::new(
1173 "Expenses:Misc",
1174 Amount::new(dec!(-70.538), "ABC"),
1175 )),
1176 ),
1177 Directive::Balance(Balance::new(
1178 date(2024, 1, 16),
1179 "Assets:Bank",
1180 Amount::new(dec!(70.53), "ABC"), )),
1182 ];
1183
1184 let errors = validate(&directives);
1185 assert!(
1186 errors.is_empty(),
1187 "Balance within tolerance should pass: {errors:?}"
1188 );
1189 }
1190
1191 #[test]
1193 fn test_validate_balance_assertion_exceeds_tolerance() {
1194 let directives = vec![
1199 Directive::Open(
1200 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1201 ),
1202 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1203 Directive::Transaction(
1204 Transaction::new(date(2024, 1, 15), "Deposit")
1205 .with_synthesized_posting(Posting::new(
1206 "Assets:Bank",
1207 Amount::new(dec!(70.542), "ABC"),
1208 ))
1209 .with_synthesized_posting(Posting::new(
1210 "Expenses:Misc",
1211 Amount::new(dec!(-70.542), "ABC"),
1212 )),
1213 ),
1214 Directive::Balance(Balance::new(
1215 date(2024, 1, 16),
1216 "Assets:Bank",
1217 Amount::new(dec!(70.53), "ABC"), )),
1219 ];
1220
1221 let errors = validate(&directives);
1222 assert!(
1223 errors
1224 .iter()
1225 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1226 "Balance exceeding tolerance should fail"
1227 );
1228 }
1229
1230 #[test]
1231 fn test_validate_unbalanced_transaction() {
1232 let directives = vec![
1233 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1234 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1235 Directive::Transaction(
1236 Transaction::new(date(2024, 1, 15), "Unbalanced")
1237 .with_synthesized_posting(Posting::new(
1238 "Assets:Bank",
1239 Amount::new(dec!(-50.00), "USD"),
1240 ))
1241 .with_synthesized_posting(Posting::new(
1242 "Expenses:Food",
1243 Amount::new(dec!(40.00), "USD"),
1244 )), ),
1246 ];
1247
1248 let errors = validate(&directives);
1249 assert!(
1250 errors
1251 .iter()
1252 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1253 );
1254 }
1255
1256 #[test]
1257 fn test_validate_currency_not_allowed() {
1258 let directives = vec![
1259 Directive::Open(
1260 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1261 ),
1262 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1263 Directive::Transaction(
1264 Transaction::new(date(2024, 1, 15), "Test")
1265 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_synthesized_posting(Posting::new(
1267 "Income:Salary",
1268 Amount::new(dec!(-100.00), "EUR"),
1269 )),
1270 ),
1271 ];
1272
1273 let errors = validate(&directives);
1274 assert!(
1275 errors
1276 .iter()
1277 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1278 );
1279 }
1280
1281 #[test]
1282 fn test_validate_future_date_warning() {
1283 let today = date(2024, 1, 1);
1287 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1288
1289 let directives = vec![Directive::Open(Open {
1290 date: future_date,
1291 account: "Assets:Bank".into(),
1292 currencies: vec![],
1293 booking: None,
1294 meta: Default::default(),
1295 })];
1296
1297 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1299 assert!(
1300 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1301 "Should not warn about future dates by default"
1302 );
1303
1304 let options = ValidationOptions::default().with_warn_future_dates(true);
1306 let errors = validate_with_today(&directives, options, today);
1307 assert!(
1308 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1309 "Should warn about future dates when enabled"
1310 );
1311 }
1312
1313 #[test]
1320 fn test_validate_with_today_threads_today_parameter() {
1321 let directives = vec![Directive::Open(Open {
1322 date: date(2024, 6, 15),
1323 account: "Assets:Bank".into(),
1324 currencies: vec![],
1325 booking: None,
1326 meta: Default::default(),
1327 })];
1328 let options = ValidationOptions::default().with_warn_future_dates(true);
1329
1330 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1332 assert!(
1333 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1334 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1335 );
1336
1337 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1339 assert!(
1340 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1341 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1342 );
1343 }
1344
1345 #[test]
1346 fn test_validate_document_not_found() {
1347 let directives = vec![
1348 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1349 Directive::Document(Document {
1350 date: date(2024, 1, 15),
1351 account: "Assets:Bank".into(),
1352 path: "/nonexistent/path/to/document.pdf".to_string(),
1353 tags: vec![],
1354 links: vec![],
1355 meta: Default::default(),
1356 }),
1357 ];
1358
1359 let errors = validate(&directives);
1361 assert!(
1362 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1363 "Should check documents by default"
1364 );
1365
1366 let options = ValidationOptions::default().with_check_documents(false);
1368 let errors = validate_with_options(&directives, options);
1369 assert!(
1370 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1371 "Should not report missing document when disabled"
1372 );
1373 }
1374
1375 #[test]
1376 fn test_validate_document_account_not_open() {
1377 let directives = vec![Directive::Document(Document {
1378 date: date(2024, 1, 15),
1379 account: "Assets:Unknown".into(),
1380 path: "receipt.pdf".to_string(),
1381 tags: vec![],
1382 links: vec![],
1383 meta: Default::default(),
1384 })];
1385
1386 let errors = validate(&directives);
1387 assert!(
1388 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1389 "Should error for document on unopened account"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_validate_document_relative_path_in_document_dirs() {
1395 let filename = "rustledger_test_889_relative_receipt.pdf";
1399 let dir = tempfile::tempdir().unwrap();
1400 let doc_subdir = dir.path().join("documents");
1401 std::fs::create_dir_all(&doc_subdir).unwrap();
1402 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1403
1404 let directives = vec![
1405 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1406 Directive::Document(Document {
1407 date: date(2024, 1, 15),
1408 account: "Assets:Bank".into(),
1409 path: filename.to_string(),
1410 tags: vec![],
1411 links: vec![],
1412 meta: Default::default(),
1413 }),
1414 ];
1415
1416 let errors = validate(&directives);
1418 assert!(
1419 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1420 "Should error when document_dirs not set"
1421 );
1422
1423 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1425 let errors = validate_with_options(&directives, options);
1426 assert!(
1427 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1428 "Should find document in document_dirs: {errors:?}"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_validate_document_relative_path_not_found_in_dirs() {
1434 let filename = "rustledger_test_889_nonexistent.pdf";
1436 let dir = tempfile::tempdir().unwrap();
1437 let doc_subdir = dir.path().join("documents");
1438 std::fs::create_dir_all(&doc_subdir).unwrap();
1439
1440 let directives = vec![
1441 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1442 Directive::Document(Document {
1443 date: date(2024, 1, 15),
1444 account: "Assets:Bank".into(),
1445 path: filename.to_string(),
1446 tags: vec![],
1447 links: vec![],
1448 meta: Default::default(),
1449 }),
1450 ];
1451
1452 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1453 let errors = validate_with_options(&directives, options);
1454 assert!(
1455 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1456 "Should error when file not found in any document_dir"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_validate_document_absolute_path_ignores_document_dirs() {
1462 let filename = "rustledger_test_889_absolute_receipt.pdf";
1463 let dir = tempfile::tempdir().unwrap();
1464 let doc_subdir = dir.path().join("documents");
1465 std::fs::create_dir_all(&doc_subdir).unwrap();
1466 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1467
1468 let directives = vec![
1469 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1470 Directive::Document(Document {
1471 date: date(2024, 1, 15),
1472 account: "Assets:Bank".into(),
1473 path: doc_subdir.join(filename).display().to_string(),
1474 tags: vec![],
1475 links: vec![],
1476 meta: Default::default(),
1477 }),
1478 ];
1479
1480 let options = ValidationOptions::default()
1482 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1483 let errors = validate_with_options(&directives, options);
1484 assert!(
1485 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1486 "Absolute path should work even with wrong document_dirs: {errors:?}"
1487 );
1488 }
1489
1490 #[test]
1499 fn test_validate_document_parallel_batch_check() {
1500 let dir = tempfile::tempdir().unwrap();
1501 let doc_subdir = dir.path().join("docs");
1502 std::fs::create_dir_all(&doc_subdir).unwrap();
1503
1504 let mut directives: Vec<Directive> =
1507 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1508 for i in 0..100 {
1509 let filename = format!("receipt_{i}.pdf");
1510 if i % 2 == 0 {
1511 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1512 }
1513 directives.push(Directive::Document(Document {
1514 date: date(2024, 1, 15),
1515 account: "Assets:Bank".into(),
1516 path: filename,
1517 tags: vec![],
1518 links: vec![],
1519 meta: Default::default(),
1520 }));
1521 }
1522
1523 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1524 let errors = validate_with_options(&directives, options);
1525
1526 let not_found_count = errors
1527 .iter()
1528 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1529 .count();
1530 assert_eq!(
1531 not_found_count, 50,
1532 "exactly 50 of 100 documents should error as not-found"
1533 );
1534
1535 let example = errors
1539 .iter()
1540 .find(|e| e.code == ErrorCode::DocumentNotFound)
1541 .expect("should have at least one not-found error");
1542 assert!(
1543 example
1544 .context
1545 .as_deref()
1546 .is_some_and(|c| c.contains("searched")),
1547 "error context should mention the searched dirs, got: {:?}",
1548 example.context
1549 );
1550 }
1551
1552 #[test]
1553 fn test_error_code_is_warning() {
1554 assert!(!ErrorCode::AccountNotOpen.is_warning());
1555 assert!(!ErrorCode::DocumentNotFound.is_warning());
1556 assert!(ErrorCode::FutureDate.is_warning());
1557 }
1558
1559 #[test]
1560 fn test_validate_pad_basic() {
1561 let directives = vec![
1562 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1563 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1564 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1565 Directive::Balance(Balance::new(
1566 date(2024, 1, 2),
1567 "Assets:Bank",
1568 Amount::new(dec!(1000.00), "USD"),
1569 )),
1570 ];
1571
1572 let errors = validate(&directives);
1573 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1575 }
1576
1577 #[test]
1578 fn test_validate_pad_with_existing_balance() {
1579 let directives = vec![
1580 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1581 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1582 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1583 Directive::Transaction(
1585 Transaction::new(date(2024, 1, 5), "Initial deposit")
1586 .with_synthesized_posting(Posting::new(
1587 "Assets:Bank",
1588 Amount::new(dec!(500.00), "USD"),
1589 ))
1590 .with_synthesized_posting(Posting::new(
1591 "Income:Salary",
1592 Amount::new(dec!(-500.00), "USD"),
1593 )),
1594 ),
1595 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1597 Directive::Balance(Balance::new(
1598 date(2024, 1, 15),
1599 "Assets:Bank",
1600 Amount::new(dec!(1000.00), "USD"), )),
1602 ];
1603
1604 let errors = validate(&directives);
1605 assert!(
1607 errors.is_empty(),
1608 "Pad should add missing amount: {errors:?}"
1609 );
1610 }
1611
1612 #[test]
1613 fn test_validate_pad_account_not_open() {
1614 let directives = vec![
1615 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1616 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1618 ];
1619
1620 let errors = validate(&directives);
1621 assert!(
1622 errors
1623 .iter()
1624 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1625 "Should error for pad on unopened account"
1626 );
1627 }
1628
1629 #[test]
1630 fn test_validate_pad_source_not_open() {
1631 let directives = vec![
1632 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1633 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1635 ];
1636
1637 let errors = validate(&directives);
1638 assert!(
1639 errors.iter().any(
1640 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1641 ),
1642 "Should error for pad with unopened source account"
1643 );
1644 }
1645
1646 #[test]
1647 fn test_validate_pad_negative_adjustment() {
1648 let directives = vec![
1650 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1651 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1652 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1653 Directive::Transaction(
1655 Transaction::new(date(2024, 1, 5), "Big deposit")
1656 .with_synthesized_posting(Posting::new(
1657 "Assets:Bank",
1658 Amount::new(dec!(2000.00), "USD"),
1659 ))
1660 .with_synthesized_posting(Posting::new(
1661 "Income:Salary",
1662 Amount::new(dec!(-2000.00), "USD"),
1663 )),
1664 ),
1665 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1667 Directive::Balance(Balance::new(
1668 date(2024, 1, 15),
1669 "Assets:Bank",
1670 Amount::new(dec!(1000.00), "USD"), )),
1672 ];
1673
1674 let errors = validate(&directives);
1675 assert!(
1676 errors.is_empty(),
1677 "Pad should handle negative adjustment: {errors:?}"
1678 );
1679 }
1680
1681 #[test]
1682 fn test_validate_insufficient_units() {
1683 use rustledger_core::CostSpec;
1684
1685 let cost_spec = CostSpec::empty()
1686 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1687 .with_currency("USD");
1688
1689 let directives = vec![
1690 Directive::Open(
1691 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1692 ),
1693 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1694 Directive::Transaction(
1696 Transaction::new(date(2024, 1, 15), "Buy")
1697 .with_synthesized_posting(
1698 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1699 .with_cost(cost_spec.clone()),
1700 )
1701 .with_synthesized_posting(Posting::new(
1702 "Assets:Cash",
1703 Amount::new(dec!(-1500), "USD"),
1704 )),
1705 ),
1706 Directive::Transaction(
1708 Transaction::new(date(2024, 6, 1), "Sell too many")
1709 .with_synthesized_posting(
1710 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1711 .with_cost(cost_spec),
1712 )
1713 .with_synthesized_posting(Posting::new(
1714 "Assets:Cash",
1715 Amount::new(dec!(2250), "USD"),
1716 )),
1717 ),
1718 ];
1719
1720 let errors = validate(&directives);
1721 assert!(
1722 errors
1723 .iter()
1724 .any(|e| e.code == ErrorCode::InsufficientUnits),
1725 "Should error for insufficient units: {errors:?}"
1726 );
1727 }
1728
1729 #[test]
1730 fn test_validate_no_matching_lot() {
1731 use rustledger_core::CostSpec;
1732
1733 let directives = vec![
1734 Directive::Open(
1735 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1736 ),
1737 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1738 Directive::Transaction(
1740 Transaction::new(date(2024, 1, 15), "Buy")
1741 .with_synthesized_posting(
1742 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1743 CostSpec::empty()
1744 .with_number(rustledger_core::CostNumber::PerUnit {
1745 value: dec!(150),
1746 })
1747 .with_currency("USD"),
1748 ),
1749 )
1750 .with_synthesized_posting(Posting::new(
1751 "Assets:Cash",
1752 Amount::new(dec!(-1500), "USD"),
1753 )),
1754 ),
1755 Directive::Transaction(
1757 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1758 .with_synthesized_posting(
1759 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1760 CostSpec::empty()
1761 .with_number(rustledger_core::CostNumber::PerUnit {
1762 value: dec!(160),
1763 })
1764 .with_currency("USD"),
1765 ),
1766 )
1767 .with_synthesized_posting(Posting::new(
1768 "Assets:Cash",
1769 Amount::new(dec!(800), "USD"),
1770 )),
1771 ),
1772 ];
1773
1774 let errors = validate(&directives);
1775 assert!(
1776 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1777 "Should error for no matching lot: {errors:?}"
1778 );
1779 }
1780
1781 #[test]
1782 fn test_validate_multiple_lot_match_uses_fifo() {
1783 use rustledger_core::CostSpec;
1786
1787 let cost_spec = CostSpec::empty()
1788 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1789 .with_currency("USD");
1790
1791 let directives = vec![
1792 Directive::Open(
1793 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1794 ),
1795 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1796 Directive::Transaction(
1798 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1799 .with_synthesized_posting(
1800 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1801 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1802 )
1803 .with_synthesized_posting(Posting::new(
1804 "Assets:Cash",
1805 Amount::new(dec!(-1500), "USD"),
1806 )),
1807 ),
1808 Directive::Transaction(
1810 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1811 .with_synthesized_posting(
1812 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1813 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1814 )
1815 .with_synthesized_posting(Posting::new(
1816 "Assets:Cash",
1817 Amount::new(dec!(-1500), "USD"),
1818 )),
1819 ),
1820 Directive::Transaction(
1822 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1823 .with_synthesized_posting(
1824 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1825 .with_cost(cost_spec),
1826 )
1827 .with_synthesized_posting(Posting::new(
1828 "Assets:Cash",
1829 Amount::new(dec!(750), "USD"),
1830 )),
1831 ),
1832 ];
1833
1834 let errors = validate(&directives);
1835 let booking_errors: Vec<_> = errors
1837 .iter()
1838 .filter(|e| {
1839 matches!(
1840 e.code,
1841 ErrorCode::InsufficientUnits
1842 | ErrorCode::NoMatchingLot
1843 | ErrorCode::AmbiguousLotMatch
1844 )
1845 })
1846 .collect();
1847 assert!(
1848 booking_errors.is_empty(),
1849 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1850 );
1851 }
1852
1853 #[test]
1854 fn test_validate_successful_booking() {
1855 use rustledger_core::CostSpec;
1856
1857 let cost_spec = CostSpec::empty()
1858 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1859 .with_currency("USD");
1860
1861 let directives = vec![
1862 Directive::Open(
1863 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1864 ),
1865 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1866 Directive::Transaction(
1868 Transaction::new(date(2024, 1, 15), "Buy")
1869 .with_synthesized_posting(
1870 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1871 .with_cost(cost_spec.clone()),
1872 )
1873 .with_synthesized_posting(Posting::new(
1874 "Assets:Cash",
1875 Amount::new(dec!(-1500), "USD"),
1876 )),
1877 ),
1878 Directive::Transaction(
1880 Transaction::new(date(2024, 6, 1), "Sell")
1881 .with_synthesized_posting(
1882 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1883 .with_cost(cost_spec),
1884 )
1885 .with_synthesized_posting(Posting::new(
1886 "Assets:Cash",
1887 Amount::new(dec!(750), "USD"),
1888 )),
1889 ),
1890 ];
1891
1892 let errors = validate(&directives);
1893 let booking_errors: Vec<_> = errors
1895 .iter()
1896 .filter(|e| {
1897 matches!(
1898 e.code,
1899 ErrorCode::InsufficientUnits
1900 | ErrorCode::NoMatchingLot
1901 | ErrorCode::AmbiguousLotMatch
1902 )
1903 })
1904 .collect();
1905 assert!(
1906 booking_errors.is_empty(),
1907 "Should have no booking errors: {booking_errors:?}"
1908 );
1909 }
1910
1911 #[test]
1912 fn test_validate_account_already_open() {
1913 let directives = vec![
1914 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1915 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1917
1918 let errors = validate(&directives);
1919 assert!(
1920 errors
1921 .iter()
1922 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1923 "Should error for duplicate open: {errors:?}"
1924 );
1925 }
1926
1927 #[test]
1928 fn test_validate_account_close_not_empty() {
1929 let directives = vec![
1930 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1931 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1932 Directive::Transaction(
1933 Transaction::new(date(2024, 1, 15), "Deposit")
1934 .with_synthesized_posting(Posting::new(
1935 "Assets:Bank",
1936 Amount::new(dec!(100.00), "USD"),
1937 ))
1938 .with_synthesized_posting(Posting::new(
1939 "Income:Salary",
1940 Amount::new(dec!(-100.00), "USD"),
1941 )),
1942 ),
1943 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1945
1946 let errors = validate(&directives);
1947 assert!(
1948 errors
1949 .iter()
1950 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1951 "Should warn for closing account with balance: {errors:?}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_validate_no_postings_allowed() {
1957 let directives = vec![
1960 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1961 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1962 ];
1963
1964 let errors = validate(&directives);
1965 assert!(
1966 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1967 "Should NOT error for transaction with no postings: {errors:?}"
1968 );
1969 }
1970
1971 #[test]
1972 fn test_validate_single_posting() {
1973 let directives = vec![
1974 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1975 Directive::Transaction(
1976 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
1977 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1978 ),
1979 ),
1980 ];
1981
1982 let errors = validate(&directives);
1983 assert!(
1984 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1985 "Should warn for transaction with single posting: {errors:?}"
1986 );
1987 assert!(ErrorCode::SinglePosting.is_warning());
1989 }
1990
1991 #[test]
1992 fn test_validate_single_posting_zero_cost_no_warning() {
1993 let directives = vec![
1997 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1998 Directive::Transaction(
1999 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2000 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2001 rustledger_core::CostSpec::empty()
2002 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2003 .with_currency("USD"),
2004 ),
2005 ),
2006 ),
2007 ];
2008
2009 let errors = validate(&directives);
2010 assert!(
2011 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2012 "Should NOT warn for zero-cost single posting: {errors:?}"
2013 );
2014 }
2015
2016 #[test]
2017 fn test_validate_single_posting_nonzero_cost_still_warns() {
2018 let directives = vec![
2020 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2021 Directive::Transaction(
2022 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2023 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2024 rustledger_core::CostSpec::empty()
2025 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2026 .with_currency("USD"),
2027 ),
2028 ),
2029 ),
2030 ];
2031
2032 let errors = validate(&directives);
2033 assert!(
2034 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2035 "Should warn for single posting with non-zero cost: {errors:?}"
2036 );
2037 }
2038
2039 #[test]
2040 fn test_validate_pad_without_balance() {
2041 let directives = vec![
2042 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2043 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2044 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2045 ];
2047
2048 let errors = validate(&directives);
2049 assert!(
2050 errors
2051 .iter()
2052 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2053 "Should error for pad without subsequent balance: {errors:?}"
2054 );
2055 }
2056
2057 #[test]
2058 fn test_validate_multiple_pads_for_balance() {
2059 let directives = vec![
2060 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2061 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2062 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2063 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2065 date(2024, 1, 3),
2066 "Assets:Bank",
2067 Amount::new(dec!(1000.00), "USD"),
2068 )),
2069 ];
2070
2071 let errors = validate(&directives);
2072 assert!(
2073 errors
2074 .iter()
2075 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2076 "Should error for multiple pads before balance: {errors:?}"
2077 );
2078 }
2079
2080 #[test]
2081 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2082 let directives = vec![
2088 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2089 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2090 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2092 Directive::Balance(Balance::new(
2093 date(2024, 1, 2),
2094 "Assets:Bank",
2095 Amount::new(dec!(100.00), "USD"),
2096 )),
2097 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2100 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2101 Directive::Balance(Balance::new(
2102 date(2024, 2, 3),
2103 "Assets:Bank",
2104 Amount::new(dec!(200.00), "USD"),
2105 )),
2106 ];
2107
2108 let errors = validate(&directives);
2109 let multi_pad_count = errors
2110 .iter()
2111 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2112 .count();
2113 assert_eq!(
2114 multi_pad_count, 1,
2115 "E2004 must fire exactly once on the second balance; got {errors:?}"
2116 );
2117 }
2118
2119 #[test]
2120 fn test_pad_serves_multi_currency_balances_on_same_day() {
2121 let directives = vec![
2128 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2129 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2130 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2131 Directive::Balance(Balance::new(
2133 date(2024, 1, 2),
2134 "Assets:Bank",
2135 Amount::new(dec!(100.00), "USD"),
2136 )),
2137 Directive::Balance(Balance::new(
2138 date(2024, 1, 2),
2139 "Assets:Bank",
2140 Amount::new(dec!(50.00), "EUR"),
2141 )),
2142 ];
2143
2144 let errors = validate(&directives);
2145 assert!(
2146 !errors
2147 .iter()
2148 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2149 "pad should serve both USD and EUR; got {errors:?}"
2150 );
2151 assert!(
2152 !errors
2153 .iter()
2154 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2155 "pad serves at least one balance; should not be E2003; got {errors:?}"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2161 let directives = vec![
2166 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2167 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2168 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2169 Directive::Balance(Balance::new(
2170 date(2024, 1, 2),
2171 "Assets:Bank",
2172 Amount::new(dec!(100.00), "USD"),
2173 )),
2174 ];
2175
2176 let errors = validate(&directives);
2177 assert!(
2181 errors
2182 .iter()
2183 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2184 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2185 );
2186 assert!(
2188 errors
2189 .iter()
2190 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2191 "same-day pad never consumed; expected E2003; got {errors:?}"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_future_pad_does_not_apply_to_earlier_balance() {
2197 let directives = vec![
2203 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2204 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2205 Directive::Balance(Balance::new(
2206 date(2024, 1, 2),
2207 "Assets:Bank",
2208 Amount::new(dec!(0.00), "USD"),
2209 )),
2210 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2211 ];
2212
2213 let errors = validate(&directives);
2214 assert!(
2217 !errors
2218 .iter()
2219 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2220 "future pad should not influence earlier balance; got {errors:?}"
2221 );
2222 assert!(
2224 errors
2225 .iter()
2226 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2227 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2228 );
2229 }
2230
2231 #[test]
2232 fn test_error_severity() {
2233 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2235 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2236 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2237
2238 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2240 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2241 assert_eq!(
2242 ErrorCode::AccountCloseNotEmpty.severity(),
2243 Severity::Warning
2244 );
2245
2246 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2248 }
2249
2250 #[test]
2251 fn test_validate_invalid_account_name() {
2252 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2254
2255 let errors = validate(&directives);
2256 assert!(
2257 errors
2258 .iter()
2259 .any(|e| e.code == ErrorCode::InvalidAccountName),
2260 "Should error for invalid account root: {errors:?}"
2261 );
2262 }
2263
2264 #[test]
2265 fn test_validate_account_lowercase_component() {
2266 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2268
2269 let errors = validate(&directives);
2270 assert!(
2271 errors
2272 .iter()
2273 .any(|e| e.code == ErrorCode::InvalidAccountName),
2274 "Should error for lowercase component: {errors:?}"
2275 );
2276 }
2277
2278 #[test]
2279 fn test_validate_valid_account_names() {
2280 let valid_names = [
2282 "Assets:Bank",
2283 "Assets:Bank:Checking",
2284 "Liabilities:CreditCard",
2285 "Equity:Opening-Balances",
2286 "Income:Salary2024",
2287 "Expenses:Food:Restaurant",
2288 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2294
2295 for name in valid_names {
2296 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2297
2298 let errors = validate(&directives);
2299 let name_errors: Vec<_> = errors
2300 .iter()
2301 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2302 .collect();
2303 assert!(
2304 name_errors.is_empty(),
2305 "Should accept valid account name '{name}': {name_errors:?}"
2306 );
2307 }
2308 }
2309
2310 #[test]
2315 fn test_e2002_balance_exceeds_explicit_tolerance() {
2316 let directives = vec![
2319 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2320 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2321 Directive::Transaction(
2322 Transaction::new(date(2024, 1, 15), "Deposit")
2323 .with_synthesized_posting(Posting::new(
2324 "Assets:Bank",
2325 Amount::new(dec!(1000.00), "USD"),
2326 ))
2327 .with_synthesized_posting(Posting::new(
2328 "Income:Salary",
2329 Amount::new(dec!(-1000.00), "USD"),
2330 )),
2331 ),
2332 Directive::Balance(
2335 Balance::new(
2336 date(2024, 1, 16),
2337 "Assets:Bank",
2338 Amount::new(dec!(999.00), "USD"),
2339 )
2340 .with_tolerance(dec!(0.01)),
2341 ),
2342 ];
2343
2344 let errors = validate(&directives);
2345
2346 assert!(
2347 errors
2348 .iter()
2349 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2350 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn test_e2002_balance_within_explicit_tolerance_passes() {
2356 let directives = vec![
2358 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2359 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2360 Directive::Transaction(
2361 Transaction::new(date(2024, 1, 15), "Deposit")
2362 .with_synthesized_posting(Posting::new(
2363 "Assets:Bank",
2364 Amount::new(dec!(1000.00), "USD"),
2365 ))
2366 .with_synthesized_posting(Posting::new(
2367 "Income:Salary",
2368 Amount::new(dec!(-1000.00), "USD"),
2369 )),
2370 ),
2371 Directive::Balance(
2373 Balance::new(
2374 date(2024, 1, 16),
2375 "Assets:Bank",
2376 Amount::new(dec!(999.00), "USD"),
2377 )
2378 .with_tolerance(dec!(5.00)),
2379 ),
2380 ];
2381
2382 let errors = validate(&directives);
2383
2384 assert!(
2385 !errors
2386 .iter()
2387 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2388 || e.code == ErrorCode::BalanceAssertionFailed),
2389 "Expected no balance errors, got: {errors:?}"
2390 );
2391 }
2392
2393 #[test]
2394 fn test_e5001_undeclared_currency() {
2395 use rustledger_core::Commodity;
2398
2399 let directives = vec![
2400 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2401 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2402 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2403 Directive::Transaction(
2404 Transaction::new(date(2024, 1, 15), "Lunch")
2405 .with_synthesized_posting(Posting::new(
2406 "Expenses:Food",
2407 Amount::new(dec!(20.00), "EUR"), ))
2409 .with_synthesized_posting(Posting::new(
2410 "Assets:Bank",
2411 Amount::new(dec!(-20.00), "EUR"),
2412 )),
2413 ),
2414 ];
2415
2416 let options = ValidationOptions::default().with_require_commodities(true);
2417 let errors = validate_with_options(&directives, options);
2418
2419 assert!(
2420 errors
2421 .iter()
2422 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2423 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2424 );
2425 }
2426
2427 #[test]
2428 fn test_e5001_declared_currency_passes() {
2429 use rustledger_core::Commodity;
2431
2432 let directives = vec![
2433 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2434 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2435 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2436 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2437 Directive::Transaction(
2438 Transaction::new(date(2024, 1, 15), "Lunch")
2439 .with_synthesized_posting(Posting::new(
2440 "Expenses:Food",
2441 Amount::new(dec!(20.00), "EUR"),
2442 ))
2443 .with_synthesized_posting(Posting::new(
2444 "Assets:Bank",
2445 Amount::new(dec!(-20.00), "EUR"),
2446 )),
2447 ),
2448 ];
2449
2450 let options = ValidationOptions::default().with_require_commodities(true);
2451 let errors = validate_with_options(&directives, options);
2452
2453 assert!(
2454 !errors
2455 .iter()
2456 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2457 "Expected no E5001 errors, got: {errors:?}"
2458 );
2459 }
2460
2461 #[test]
2462 fn test_e5001_not_raised_without_require_commodities() {
2463 let directives = vec![
2465 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2466 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2467 Directive::Transaction(
2468 Transaction::new(date(2024, 1, 15), "Lunch")
2469 .with_synthesized_posting(Posting::new(
2470 "Expenses:Food",
2471 Amount::new(dec!(20.00), "XYZ"), ))
2473 .with_synthesized_posting(Posting::new(
2474 "Assets:Bank",
2475 Amount::new(dec!(-20.00), "XYZ"),
2476 )),
2477 ),
2478 ];
2479
2480 let errors = validate(&directives);
2481
2482 assert!(
2483 !errors
2484 .iter()
2485 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2486 "Should not raise E5001 without require_commodities, got: {errors:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn test_e3002_multiple_missing_amounts() {
2492 let directives = vec![
2494 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2495 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2496 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2497 Directive::Transaction(
2498 Transaction::new(date(2024, 1, 15), "Lunch")
2499 .with_synthesized_posting(Posting::new(
2500 "Assets:Bank",
2501 Amount::new(dec!(-50.00), "USD"),
2502 ))
2503 .with_synthesized_posting(Posting {
2505 account: "Expenses:Food".into(),
2506 units: None,
2507 cost: None,
2508 price: None,
2509 flag: None,
2510 meta: Default::default(),
2511 comments: vec![],
2512 trailing_comments: vec![],
2513 })
2514 .with_synthesized_posting(Posting {
2515 account: "Expenses:Drinks".into(),
2516 units: None,
2517 cost: None,
2518 price: None,
2519 flag: None,
2520 meta: Default::default(),
2521 comments: vec![],
2522 trailing_comments: vec![],
2523 }),
2524 ),
2525 ];
2526
2527 let errors = validate(&directives);
2528
2529 assert!(
2530 errors
2531 .iter()
2532 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2533 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2534 );
2535 }
2536
2537 #[test]
2538 fn test_e3002_single_missing_amount_ok() {
2539 let directives = vec![
2541 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2542 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2543 Directive::Transaction(
2544 Transaction::new(date(2024, 1, 15), "Lunch")
2545 .with_synthesized_posting(Posting::new(
2546 "Assets:Bank",
2547 Amount::new(dec!(-50.00), "USD"),
2548 ))
2549 .with_synthesized_posting(Posting {
2550 account: "Expenses:Food".into(),
2551 units: None,
2552 cost: None,
2553 price: None,
2554 flag: None,
2555 meta: Default::default(),
2556 comments: vec![],
2557 trailing_comments: vec![],
2558 }),
2559 ),
2560 ];
2561
2562 let errors = validate(&directives);
2563
2564 assert!(
2565 !errors
2566 .iter()
2567 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2568 "Should not raise E3002 with single missing amount, got: {errors:?}"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_e7001_unknown_option() {
2574 let state = LedgerState::new();
2576 let mut errors = Vec::new();
2577
2578 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2579
2580 assert_eq!(errors.len(), 1);
2581 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2582 assert!(errors[0].message.contains("bogus_option"));
2583 }
2584
2585 #[test]
2586 fn test_e7002_invalid_option_value() {
2587 let state = LedgerState::new();
2588 let mut errors = Vec::new();
2589
2590 state.import_option_warnings(
2591 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2592 &mut errors,
2593 );
2594
2595 assert_eq!(errors.len(), 1);
2596 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2597 }
2598
2599 #[test]
2600 fn test_e7003_duplicate_option() {
2601 let state = LedgerState::new();
2602 let mut errors = Vec::new();
2603
2604 state.import_option_warnings(
2605 &[("E7003", "Option \"title\" can only be specified once")],
2606 &mut errors,
2607 );
2608
2609 assert_eq!(errors.len(), 1);
2610 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2611 }
2612
2613 fn commodity_with_precision(value: MetaValue) -> Directive {
2616 let mut meta = rustledger_core::Metadata::default();
2617 meta.insert("precision".into(), value);
2618 Directive::Commodity(
2619 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2620 )
2621 }
2622
2623 #[test]
2624 fn precision_meta_valid_integer_emits_no_warning() {
2625 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2626 let errors = validate(&directives);
2627 assert!(
2628 errors
2629 .iter()
2630 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2631 "valid precision must not produce a warning, got: {errors:?}"
2632 );
2633 }
2634
2635 #[test]
2636 fn precision_meta_zero_is_valid() {
2637 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2638 let errors = validate(&directives);
2639 assert!(
2640 errors
2641 .iter()
2642 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2643 );
2644 }
2645
2646 #[test]
2647 fn precision_meta_negative_emits_e5003() {
2648 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2649 let errors = validate(&directives);
2650 let warnings: Vec<_> = errors
2651 .iter()
2652 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2653 .collect();
2654 assert_eq!(warnings.len(), 1, "expected one E5003");
2655 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2656 assert!(warnings[0].message.contains("non-negative"));
2657 }
2658
2659 #[test]
2660 fn precision_meta_non_integer_emits_e5003() {
2661 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2662 let errors = validate(&directives);
2663 let warnings: Vec<_> = errors
2664 .iter()
2665 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2666 .collect();
2667 assert_eq!(warnings.len(), 1);
2668 assert!(warnings[0].message.contains("integer"));
2669 }
2670
2671 #[test]
2672 fn precision_meta_string_value_emits_e5003() {
2673 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2674 let errors = validate(&directives);
2675 let warnings: Vec<_> = errors
2676 .iter()
2677 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2678 .collect();
2679 assert_eq!(warnings.len(), 1);
2680 assert!(warnings[0].message.contains("string"));
2681 }
2682
2683 #[test]
2684 fn precision_meta_out_of_u32_range_emits_e5003() {
2685 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2687 8589934592
2688 )))];
2689 let errors = validate(&directives);
2690 let warnings: Vec<_> = errors
2691 .iter()
2692 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2693 .collect();
2694 assert_eq!(warnings.len(), 1);
2695 assert!(warnings[0].message.contains("exceeds"));
2696 }
2697
2698 #[test]
2699 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2700 let directives = vec![
2705 commodity_with_precision(MetaValue::Number(dec!(2))),
2706 commodity_with_precision(MetaValue::Number(dec!(-1))),
2707 ];
2708 let warnings: Vec<_> = validate(&directives)
2709 .into_iter()
2710 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2711 .collect();
2712 assert_eq!(
2713 warnings.len(),
2714 1,
2715 "exactly one E5003 expected (only the invalid declaration)"
2716 );
2717 assert!(warnings[0].message.contains("non-negative"));
2718 }
2719
2720 #[test]
2721 fn precision_meta_e5003_is_warning_severity() {
2722 assert_eq!(
2726 ErrorCode::InvalidPrecisionMetadata.severity(),
2727 Severity::Warning
2728 );
2729 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2730 }
2731
2732 #[test]
2740 fn test_validate_early_emits_e1001_on_elided_posting() {
2741 let directives = vec![
2742 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2743 Directive::Transaction(
2744 Transaction::new(date(2024, 1, 15), "Zero to unopened")
2745 .with_synthesized_posting(Posting::new(
2746 "Assets:Bank",
2747 Amount::new(dec!(0.00), "USD"),
2748 ))
2749 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
2750 ),
2751 ];
2752
2753 let session = ValidationSession::new(ValidationOptions::default());
2754 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
2755
2756 assert!(
2757 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
2758 && e.to_string().contains("Expenses:NeverOpened")),
2759 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
2760 );
2761 }
2762
2763 #[test]
2767 fn test_validate_late_does_not_duplicate_e1001() {
2768 let directives = vec![
2769 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2770 Directive::Transaction(
2771 Transaction::new(date(2024, 1, 15), "To unopened")
2772 .with_synthesized_posting(Posting::new(
2773 "Assets:Bank",
2774 Amount::new(dec!(100), "USD"),
2775 ))
2776 .with_synthesized_posting(Posting::new(
2777 "Expenses:NeverOpened",
2778 Amount::new(dec!(-100), "USD"),
2779 )),
2780 ),
2781 ];
2782
2783 let session = ValidationSession::new(ValidationOptions::default());
2784 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
2785 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
2786
2787 let early_e1001 = early
2788 .iter()
2789 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2790 .count();
2791 let late_e1001 = late
2792 .iter()
2793 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2794 .count();
2795
2796 assert_eq!(early_e1001, 1, "early phase should emit E1001 once");
2797 assert_eq!(
2798 late_e1001, 0,
2799 "late phase must not re-emit account-presence errors; got: {late:?}"
2800 );
2801 }
2802
2803 #[test]
2809 fn test_validate_chained_matches_explicit_phases() {
2810 let directives = vec![
2814 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2815 Directive::Transaction(
2816 Transaction::new(date(2024, 1, 15), "Mixed")
2817 .with_synthesized_posting(Posting::new(
2818 "Assets:Bank",
2819 Amount::new(dec!(50), "USD"),
2820 ))
2821 .with_synthesized_posting(Posting::new(
2822 "Income:Salary",
2823 Amount::new(dec!(-50), "USD"),
2824 )),
2825 ),
2826 Directive::Balance(Balance::new(
2827 date(2024, 1, 16),
2828 "Assets:Bank",
2829 Amount::new(dec!(50), "USD"),
2830 )),
2831 ];
2832
2833 let chained = validate(&directives);
2835
2836 let session = ValidationSession::new(ValidationOptions::default());
2838 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
2839 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
2840 explicit.extend(late_errs);
2841 explicit.extend(session.finalize());
2842
2843 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
2847 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
2848 assert_eq!(
2849 chained_strs, explicit_strs,
2850 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
2851 );
2852 }
2853
2854 #[test]
2855 fn test_phase_order_early_then_late_then_finalize() {
2856 let directives = vec![
2863 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2864 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
2865 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2866 Directive::Transaction(
2868 Transaction::new(date(2024, 1, 5), "early")
2869 .with_synthesized_posting(Posting::new(
2870 "Assets:Bank",
2871 Amount::new(dec!(100), "USD"),
2872 ))
2873 .with_synthesized_posting(Posting::new(
2874 "Income:Salary",
2875 Amount::new(dec!(-100), "USD"),
2876 )),
2877 ),
2878 Directive::Pad(Pad::new(
2880 date(2024, 1, 10),
2881 "Assets:Other",
2882 "Equity:Opening",
2883 )),
2884 Directive::Balance(Balance::new(
2886 date(2024, 2, 1),
2887 "Assets:Bank",
2888 Amount::new(dec!(999), "USD"),
2889 )),
2890 ];
2891
2892 let errors = validate(&directives);
2893 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
2894
2895 let early_pos = codes
2896 .iter()
2897 .position(|c| *c == ErrorCode::AccountNotOpen)
2898 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
2899 let late_pos = codes
2900 .iter()
2901 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
2902 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
2903 let finalize_pos = codes
2904 .iter()
2905 .position(|c| *c == ErrorCode::PadWithoutBalance)
2906 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
2907
2908 assert!(
2909 early_pos < late_pos,
2910 "early-phase errors must precede late-phase; got {codes:?}"
2911 );
2912 assert!(
2913 late_pos < finalize_pos,
2914 "late-phase errors must precede finalize; got {codes:?}"
2915 );
2916 }
2917
2918 #[test]
2919 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
2920 let directives = vec![
2927 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2928 Directive::Transaction(
2931 Transaction::new(date(2024, 1, 10), "leave residue")
2932 .with_synthesized_posting(Posting::new(
2933 "Assets:Bank",
2934 Amount::new(dec!(50), "USD"),
2935 ))
2936 .with_synthesized_posting(Posting::new(
2937 "Equity:Opening",
2938 Amount::new(dec!(-50), "USD"),
2939 )),
2940 ),
2941 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2942 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
2943 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
2944 ];
2945
2946 let errors = validate(&directives);
2947 let close_not_empty_count = errors
2948 .iter()
2949 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
2950 .count();
2951 assert_eq!(
2952 close_not_empty_count, 1,
2953 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
2954 );
2955 let account_closed_count = errors
2957 .iter()
2958 .filter(|e| e.code == ErrorCode::AccountClosed)
2959 .count();
2960 assert_eq!(
2961 account_closed_count, 1,
2962 "duplicate close should still report AccountClosed once; got {errors:?}"
2963 );
2964 }
2965
2966 #[test]
2992 fn typestate_pins_phase_ordering_at_compile_time() {
2993 fn _expect_pending_returns_early(
3005 s: ValidationSession<Pending>,
3006 ) -> ValidationSession<EarlyDone> {
3007 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3008 s
3009 }
3010 fn _expect_early_returns_late(
3011 s: ValidationSession<EarlyDone>,
3012 ) -> ValidationSession<LateDone> {
3013 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3014 s
3015 }
3016 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3017 s.finalize()
3018 }
3019 }
3020}