1#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47mod error;
48mod validators;
49
50pub use error::{ErrorCode, Severity, ValidationError};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum Phase {
79 Early,
83 Late,
87}
88
89use validators::{
90 validate_balance_early, validate_balance_late, validate_close, validate_close_late,
91 validate_document, validate_note, validate_open, validate_pad, validate_transaction_early,
92 validate_transaction_late,
93};
94
95use rayon::prelude::*;
96use rustledger_core::NaiveDate;
97
98const PARALLEL_SORT_THRESHOLD: usize = 5000;
101
102const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
106use rust_decimal::Decimal;
107use rustc_hash::{FxHashMap, FxHashSet};
108use rustledger_core::{BookingMethod, Commodity, Directive, InternedStr, Inventory};
109use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
110
111#[derive(Debug, Clone)]
113struct AccountState {
114 opened: NaiveDate,
116 closed: Option<NaiveDate>,
118 currencies: FxHashSet<InternedStr>,
120 booking: BookingMethod,
123}
124
125#[non_exhaustive]
127#[derive(Debug, Clone)]
128pub struct ValidationOptions {
129 pub require_commodities: bool,
131 pub check_documents: bool,
133 pub warn_future_dates: bool,
135 pub document_base: Option<std::path::PathBuf>,
137 pub document_dirs: Vec<std::path::PathBuf>,
141 pub account_types: Vec<String>,
144 pub infer_tolerance_from_cost: bool,
147 pub tolerance_multiplier: Decimal,
150 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
153}
154
155impl Default for ValidationOptions {
156 fn default() -> Self {
157 Self {
158 require_commodities: false,
159 check_documents: true, warn_future_dates: false,
161 document_base: None,
162 document_dirs: Vec::new(),
163 account_types: vec![
164 "Assets".to_string(),
165 "Liabilities".to_string(),
166 "Equity".to_string(),
167 "Income".to_string(),
168 "Expenses".to_string(),
169 ],
170 infer_tolerance_from_cost: false,
172 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
174 }
175 }
176}
177
178impl ValidationOptions {
179 #[must_use]
181 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
182 self.account_types = types;
183 self
184 }
185
186 #[must_use]
188 pub const fn with_require_commodities(mut self, require: bool) -> Self {
189 self.require_commodities = require;
190 self
191 }
192
193 #[must_use]
195 pub const fn with_check_documents(mut self, check: bool) -> Self {
196 self.check_documents = check;
197 self
198 }
199
200 #[must_use]
202 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
203 self.warn_future_dates = warn;
204 self
205 }
206
207 #[must_use]
209 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
210 self.document_dirs = dirs;
211 self
212 }
213
214 #[must_use]
216 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
217 self.infer_tolerance_from_cost = infer;
218 self
219 }
220
221 #[must_use]
223 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
224 self.tolerance_multiplier = multiplier;
225 self
226 }
227
228 #[must_use]
230 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
231 self.inferred_tolerance_default = defaults;
232 self
233 }
234}
235
236#[derive(Debug, Clone)]
238struct PendingPad {
239 source_account: InternedStr,
241 date: NaiveDate,
243 padded_currencies: FxHashSet<InternedStr>,
250}
251
252#[derive(Debug, Default)]
254pub struct LedgerState {
255 accounts: FxHashMap<InternedStr, AccountState>,
257 inventories: FxHashMap<InternedStr, Inventory>,
259 commodities: FxHashSet<InternedStr>,
261 pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
263 options: ValidationOptions,
265 last_date: Option<NaiveDate>,
267 pub(crate) late_close_processed: FxHashSet<(InternedStr, NaiveDate)>,
278}
279
280impl LedgerState {
281 #[must_use]
283 pub fn new() -> Self {
284 Self::default()
285 }
286
287 #[must_use]
289 pub fn with_options(options: ValidationOptions) -> Self {
290 Self {
291 options,
292 ..Default::default()
293 }
294 }
295
296 pub const fn set_require_commodities(&mut self, require: bool) {
298 self.options.require_commodities = require;
299 }
300
301 pub const fn set_check_documents(&mut self, check: bool) {
303 self.options.check_documents = check;
304 }
305
306 pub const fn set_warn_future_dates(&mut self, warn: bool) {
308 self.options.warn_future_dates = warn;
309 }
310
311 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
313 self.options.document_base = Some(base.into());
314 }
315
316 #[must_use]
318 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
319 self.inventories.get(account)
320 }
321
322 pub fn accounts(&self) -> impl Iterator<Item = &str> {
324 self.accounts.keys().map(InternedStr::as_str)
325 }
326
327 pub fn import_option_warnings(
335 &self,
336 warnings: &[(&str, &str)],
337 errors: &mut Vec<ValidationError>,
338 ) {
339 for &(code, message) in warnings {
340 let error_code = match code {
341 "E7001" => ErrorCode::UnknownOption,
342 "E7002" => ErrorCode::InvalidOptionValue,
343 "E7003" => ErrorCode::DuplicateOption,
344 _ => continue,
345 };
346 errors.push(ValidationError::new(
347 error_code,
348 message.to_string(),
349 NaiveDate::default(),
351 ));
352 }
353 }
354}
355
356trait ValidatableDirective: Sync {
365 fn directive(&self) -> &Directive;
366 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
370}
371
372impl ValidatableDirective for Directive {
373 fn directive(&self) -> &Directive {
374 self
375 }
376 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
377 None
378 }
379}
380
381impl ValidatableDirective for Spanned<Directive> {
382 fn directive(&self) -> &Directive {
383 &self.value
384 }
385 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
386 Some((self.span, self.file_id))
387 }
388}
389
390fn validate_phase_inner<D: ValidatableDirective>(
401 directives: &[D],
402 state: &mut LedgerState,
403 phase: Phase,
404 today: NaiveDate,
405) -> Vec<ValidationError> {
406 let document_exists_cache = if phase == Phase::Early {
409 build_document_exists_cache(directives, &state.options)
410 } else {
411 FxHashMap::default()
412 };
413
414 if phase == Phase::Early {
418 state.last_date = None;
419 }
420
421 let mut errors = Vec::new();
422
423 let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
428 sorted.extend(directives.iter());
429 let sort_fn = |a: &&D, b: &&D| {
430 let ad = a.directive();
431 let bd = b.directive();
432 ad.date()
433 .cmp(&bd.date())
434 .then_with(|| ad.priority().cmp(&bd.priority()))
435 .then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
436 };
437 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
438 sorted.par_sort_by(sort_fn);
439 } else {
440 sorted.sort_by(sort_fn);
441 }
442
443 for d in sorted {
444 let directive = d.directive();
445 let date = directive.date();
446
447 let error_count_before = errors.len();
454
455 if phase == Phase::Early {
459 if let Some(last) = state.last_date
460 && date < last
461 {
462 errors.push(ValidationError::new(
463 ErrorCode::DateOutOfOrder,
464 format!("Directive date {date} is before previous directive {last}"),
465 date,
466 ));
467 }
468 state.last_date = Some(date);
469
470 if state.options.warn_future_dates && date > today {
471 errors.push(ValidationError::new(
472 ErrorCode::FutureDate,
473 format!("Entry dated in the future: {date}"),
474 date,
475 ));
476 }
477 }
478
479 match (phase, directive) {
480 (Phase::Early, Directive::Open(open)) => {
482 validate_open(state, open, &mut errors);
483 }
484 (Phase::Early, Directive::Close(close)) => {
485 validate_close(state, close, &mut errors);
486 }
487 (Phase::Late, Directive::Close(close)) => {
488 validate_close_late(state, close, &mut errors);
489 }
490 (Phase::Early, Directive::Commodity(comm)) => {
491 state.commodities.insert(comm.currency.clone());
492 validate_commodity_precision_meta(comm, &mut errors);
493 }
494 (Phase::Early, Directive::Pad(pad)) => {
495 validate_pad(state, pad, &mut errors);
496 }
497 (Phase::Early, Directive::Document(doc)) => {
498 validate_document(state, doc, &document_exists_cache, &mut errors);
499 }
500 (Phase::Early, Directive::Note(note)) => {
501 validate_note(state, note, &mut errors);
502 }
503 (Phase::Early, Directive::Transaction(txn)) => {
505 validate_transaction_early(state, txn, &mut errors);
506 }
507 (Phase::Late, Directive::Transaction(txn)) => {
508 validate_transaction_late(state, txn, &mut errors);
509 }
510 (Phase::Early, Directive::Balance(bal)) => {
511 validate_balance_early(state, bal, &mut errors);
512 }
513 (Phase::Late, Directive::Balance(bal)) => {
514 validate_balance_late(state, bal, &mut errors);
515 }
516 _ => {}
518 }
519
520 if let Some((span, file_id)) = d.span_info() {
527 for error in errors.iter_mut().skip(error_count_before) {
528 if error.span.is_none() {
529 error.span = Some(span);
530 error.file_id = Some(file_id);
531 }
532 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
533 error.note = Some(
534 "directive was synthesized by a plugin (no source location); \
535 check your `plugin \"…\"` declarations for the responsible plugin"
536 .to_string(),
537 );
538 }
539 }
540 }
541 }
542
543 errors
544}
545
546fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
550 let mut errors = Vec::new();
551 for (target_account, pads) in &state.pending_pads {
552 for pad in pads {
553 if pad.padded_currencies.is_empty() {
554 errors.push(
555 ValidationError::new(
556 ErrorCode::PadWithoutBalance,
557 "Unused Pad entry".to_string(),
558 pad.date,
559 )
560 .with_context(format!(
561 " {} pad {} {}",
562 pad.date, target_account, pad.source_account
563 )),
564 );
565 }
566 }
567 }
568 errors
569}
570
571fn build_document_exists_cache<D: ValidatableDirective>(
593 directives: &[D],
594 options: &ValidationOptions,
595) -> FxHashMap<String, bool> {
596 if !options.check_documents {
597 return FxHashMap::default();
598 }
599
600 let mut paths: FxHashSet<&str> = FxHashSet::default();
605 for d in directives {
606 if let Directive::Document(doc) = d.directive() {
607 paths.insert(doc.path.as_str());
608 }
609 }
610 let paths: Vec<&str> = paths.into_iter().collect();
611
612 let resolve = |s: &str| -> (String, bool) {
618 let doc_path = std::path::Path::new(s);
619 let found = if doc_path.is_absolute() {
620 doc_path.exists()
621 } else if let Some(base) = &options.document_base {
622 base.join(doc_path).exists()
623 } else if !options.document_dirs.is_empty() {
624 options
625 .document_dirs
626 .iter()
627 .any(|dir| dir.join(doc_path).exists())
628 } else {
629 doc_path.exists()
630 };
631 (s.to_string(), found)
632 };
633
634 if paths.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
635 paths.into_par_iter().map(resolve).collect()
636 } else {
637 paths.into_iter().map(resolve).collect()
638 }
639}
640
641pub struct ValidationSession {
707 state: LedgerState,
708 phases_run: u8,
713}
714
715impl ValidationSession {
716 const PHASE_EARLY_BIT: u8 = 1 << 0;
717 const PHASE_LATE_BIT: u8 = 1 << 1;
718
719 #[must_use]
721 pub fn new(options: ValidationOptions) -> Self {
722 Self {
723 state: LedgerState::with_options(options),
724 phases_run: 0,
725 }
726 }
727
728 pub fn run_phase(
742 &mut self,
743 directives: &[Directive],
744 phase: Phase,
745 today: NaiveDate,
746 ) -> Vec<ValidationError> {
747 if !self.check_phase_ordering(phase) {
748 return Vec::new();
749 }
750 validate_phase_inner(directives, &mut self.state, phase, today)
751 }
752
753 pub fn run_phase_spanned(
760 &mut self,
761 directives: &[Spanned<Directive>],
762 phase: Phase,
763 today: NaiveDate,
764 ) -> Vec<ValidationError> {
765 if !self.check_phase_ordering(phase) {
766 return Vec::new();
767 }
768 validate_phase_inner(directives, &mut self.state, phase, today)
769 }
770
771 #[must_use]
779 pub fn finalize(self) -> Vec<ValidationError> {
780 check_unused_pads(&self.state)
781 }
782
783 fn check_phase_ordering(&mut self, phase: Phase) -> bool {
787 let bit = match phase {
788 Phase::Early => Self::PHASE_EARLY_BIT,
789 Phase::Late => Self::PHASE_LATE_BIT,
790 };
791 if self.phases_run & bit != 0 {
792 debug_assert!(
793 false,
794 "ValidationSession::run_phase{{,_spanned}} called twice for {phase:?}; \
795 each phase must run exactly once per session"
796 );
797 return false;
798 }
799 if matches!(phase, Phase::Late) && self.phases_run & Self::PHASE_EARLY_BIT == 0 {
800 debug_assert!(
801 false,
802 "ValidationSession::run_phase{{,_spanned}}(Phase::Late) called before Phase::Early; \
803 Late depends on state Early builds (open accounts, commodities, pending pads)"
804 );
805 return false;
806 }
807 self.phases_run |= bit;
808 true
809 }
810}
811
812fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
818 let Some(value) = comm.meta.get("precision") else {
819 return;
820 };
821 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
822 errors.push(ValidationError::new(
823 ErrorCode::InvalidPrecisionMetadata,
824 format!(
825 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
826 comm.currency
827 ),
828 comm.date,
829 ));
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use rust_decimal_macros::dec;
837 use rustledger_core::{
838 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
839 };
840
841 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
842 rustledger_core::naive_date(year, month, day).unwrap()
843 }
844
845 fn test_today() -> NaiveDate {
849 date(2030, 1, 1)
850 }
851
852 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
857 validate_with_options(directives, ValidationOptions::default())
858 }
859
860 fn validate_with_options(
863 directives: &[Directive],
864 options: ValidationOptions,
865 ) -> Vec<ValidationError> {
866 validate_with_today(directives, options, test_today())
867 }
868
869 fn validate_with_today(
873 directives: &[Directive],
874 options: ValidationOptions,
875 today: NaiveDate,
876 ) -> Vec<ValidationError> {
877 let mut session = ValidationSession::new(options);
878 let mut errors = session.run_phase(directives, Phase::Early, today);
879 errors.extend(session.run_phase(directives, Phase::Late, today));
880 errors.extend(session.finalize());
881 errors
882 }
883
884 #[test]
885 fn test_validate_account_lifecycle() {
886 let directives = vec![
887 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
888 Directive::Transaction(
889 Transaction::new(date(2024, 1, 15), "Test")
890 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
891 .with_posting(Posting::new(
892 "Income:Salary",
893 Amount::new(dec!(-100), "USD"),
894 )),
895 ),
896 ];
897
898 let errors = validate(&directives);
899
900 assert!(errors
902 .iter()
903 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
904 }
905
906 #[test]
907 fn test_validate_account_used_before_open() {
908 let directives = vec![
909 Directive::Transaction(
910 Transaction::new(date(2024, 1, 1), "Test")
911 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
912 .with_posting(Posting::new(
913 "Income:Salary",
914 Amount::new(dec!(-100), "USD"),
915 )),
916 ),
917 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
918 ];
919
920 let errors = validate(&directives);
921
922 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
923 }
924
925 #[test]
926 fn test_validate_account_used_after_close() {
927 let directives = vec![
928 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
929 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
930 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
931 Directive::Transaction(
932 Transaction::new(date(2024, 7, 1), "Test")
933 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
934 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
935 ),
936 ];
937
938 let errors = validate(&directives);
939
940 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
941 }
942
943 #[test]
944 fn test_validate_balance_assertion() {
945 let directives = vec![
946 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
947 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
948 Directive::Transaction(
949 Transaction::new(date(2024, 1, 15), "Deposit")
950 .with_posting(Posting::new(
951 "Assets:Bank",
952 Amount::new(dec!(1000.00), "USD"),
953 ))
954 .with_posting(Posting::new(
955 "Income:Salary",
956 Amount::new(dec!(-1000.00), "USD"),
957 )),
958 ),
959 Directive::Balance(Balance::new(
960 date(2024, 1, 16),
961 "Assets:Bank",
962 Amount::new(dec!(1000.00), "USD"),
963 )),
964 ];
965
966 let errors = validate(&directives);
967 assert!(errors.is_empty(), "{errors:?}");
968 }
969
970 #[test]
971 fn test_validate_balance_assertion_failed() {
972 let directives = vec![
973 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
974 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
975 Directive::Transaction(
976 Transaction::new(date(2024, 1, 15), "Deposit")
977 .with_posting(Posting::new(
978 "Assets:Bank",
979 Amount::new(dec!(1000.00), "USD"),
980 ))
981 .with_posting(Posting::new(
982 "Income:Salary",
983 Amount::new(dec!(-1000.00), "USD"),
984 )),
985 ),
986 Directive::Balance(Balance::new(
987 date(2024, 1, 16),
988 "Assets:Bank",
989 Amount::new(dec!(500.00), "USD"), )),
991 ];
992
993 let errors = validate(&directives);
994 assert!(
995 errors
996 .iter()
997 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
998 );
999 }
1000
1001 #[test]
1007 fn test_validate_balance_assertion_within_tolerance() {
1008 let directives = vec![
1013 Directive::Open(
1014 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1015 ),
1016 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1017 Directive::Transaction(
1018 Transaction::new(date(2024, 1, 15), "Deposit")
1019 .with_posting(Posting::new(
1020 "Assets:Bank",
1021 Amount::new(dec!(70.538), "ABC"), ))
1023 .with_posting(Posting::new(
1024 "Expenses:Misc",
1025 Amount::new(dec!(-70.538), "ABC"),
1026 )),
1027 ),
1028 Directive::Balance(Balance::new(
1029 date(2024, 1, 16),
1030 "Assets:Bank",
1031 Amount::new(dec!(70.53), "ABC"), )),
1033 ];
1034
1035 let errors = validate(&directives);
1036 assert!(
1037 errors.is_empty(),
1038 "Balance within tolerance should pass: {errors:?}"
1039 );
1040 }
1041
1042 #[test]
1044 fn test_validate_balance_assertion_exceeds_tolerance() {
1045 let directives = vec![
1050 Directive::Open(
1051 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1052 ),
1053 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1054 Directive::Transaction(
1055 Transaction::new(date(2024, 1, 15), "Deposit")
1056 .with_posting(Posting::new(
1057 "Assets:Bank",
1058 Amount::new(dec!(70.542), "ABC"),
1059 ))
1060 .with_posting(Posting::new(
1061 "Expenses:Misc",
1062 Amount::new(dec!(-70.542), "ABC"),
1063 )),
1064 ),
1065 Directive::Balance(Balance::new(
1066 date(2024, 1, 16),
1067 "Assets:Bank",
1068 Amount::new(dec!(70.53), "ABC"), )),
1070 ];
1071
1072 let errors = validate(&directives);
1073 assert!(
1074 errors
1075 .iter()
1076 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1077 "Balance exceeding tolerance should fail"
1078 );
1079 }
1080
1081 #[test]
1082 fn test_validate_unbalanced_transaction() {
1083 let directives = vec![
1084 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1085 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1086 Directive::Transaction(
1087 Transaction::new(date(2024, 1, 15), "Unbalanced")
1088 .with_posting(Posting::new(
1089 "Assets:Bank",
1090 Amount::new(dec!(-50.00), "USD"),
1091 ))
1092 .with_posting(Posting::new(
1093 "Expenses:Food",
1094 Amount::new(dec!(40.00), "USD"),
1095 )), ),
1097 ];
1098
1099 let errors = validate(&directives);
1100 assert!(
1101 errors
1102 .iter()
1103 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1104 );
1105 }
1106
1107 #[test]
1108 fn test_validate_currency_not_allowed() {
1109 let directives = vec![
1110 Directive::Open(
1111 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1112 ),
1113 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1114 Directive::Transaction(
1115 Transaction::new(date(2024, 1, 15), "Test")
1116 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
1118 "Income:Salary",
1119 Amount::new(dec!(-100.00), "EUR"),
1120 )),
1121 ),
1122 ];
1123
1124 let errors = validate(&directives);
1125 assert!(
1126 errors
1127 .iter()
1128 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1129 );
1130 }
1131
1132 #[test]
1133 fn test_validate_future_date_warning() {
1134 let today = date(2024, 1, 1);
1138 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1139
1140 let directives = vec![Directive::Open(Open {
1141 date: future_date,
1142 account: "Assets:Bank".into(),
1143 currencies: vec![],
1144 booking: None,
1145 meta: Default::default(),
1146 })];
1147
1148 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1150 assert!(
1151 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1152 "Should not warn about future dates by default"
1153 );
1154
1155 let options = ValidationOptions::default().with_warn_future_dates(true);
1157 let errors = validate_with_today(&directives, options, today);
1158 assert!(
1159 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1160 "Should warn about future dates when enabled"
1161 );
1162 }
1163
1164 #[test]
1171 fn test_validate_with_today_threads_today_parameter() {
1172 let directives = vec![Directive::Open(Open {
1173 date: date(2024, 6, 15),
1174 account: "Assets:Bank".into(),
1175 currencies: vec![],
1176 booking: None,
1177 meta: Default::default(),
1178 })];
1179 let options = ValidationOptions::default().with_warn_future_dates(true);
1180
1181 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1183 assert!(
1184 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1185 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1186 );
1187
1188 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1190 assert!(
1191 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1192 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1193 );
1194 }
1195
1196 #[test]
1197 fn test_validate_document_not_found() {
1198 let directives = vec![
1199 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1200 Directive::Document(Document {
1201 date: date(2024, 1, 15),
1202 account: "Assets:Bank".into(),
1203 path: "/nonexistent/path/to/document.pdf".to_string(),
1204 tags: vec![],
1205 links: vec![],
1206 meta: Default::default(),
1207 }),
1208 ];
1209
1210 let errors = validate(&directives);
1212 assert!(
1213 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1214 "Should check documents by default"
1215 );
1216
1217 let options = ValidationOptions::default().with_check_documents(false);
1219 let errors = validate_with_options(&directives, options);
1220 assert!(
1221 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1222 "Should not report missing document when disabled"
1223 );
1224 }
1225
1226 #[test]
1227 fn test_validate_document_account_not_open() {
1228 let directives = vec![Directive::Document(Document {
1229 date: date(2024, 1, 15),
1230 account: "Assets:Unknown".into(),
1231 path: "receipt.pdf".to_string(),
1232 tags: vec![],
1233 links: vec![],
1234 meta: Default::default(),
1235 })];
1236
1237 let errors = validate(&directives);
1238 assert!(
1239 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1240 "Should error for document on unopened account"
1241 );
1242 }
1243
1244 #[test]
1245 fn test_validate_document_relative_path_in_document_dirs() {
1246 let filename = "rustledger_test_889_relative_receipt.pdf";
1250 let dir = tempfile::tempdir().unwrap();
1251 let doc_subdir = dir.path().join("documents");
1252 std::fs::create_dir_all(&doc_subdir).unwrap();
1253 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1254
1255 let directives = vec![
1256 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1257 Directive::Document(Document {
1258 date: date(2024, 1, 15),
1259 account: "Assets:Bank".into(),
1260 path: filename.to_string(),
1261 tags: vec![],
1262 links: vec![],
1263 meta: Default::default(),
1264 }),
1265 ];
1266
1267 let errors = validate(&directives);
1269 assert!(
1270 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1271 "Should error when document_dirs not set"
1272 );
1273
1274 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1276 let errors = validate_with_options(&directives, options);
1277 assert!(
1278 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1279 "Should find document in document_dirs: {errors:?}"
1280 );
1281 }
1282
1283 #[test]
1284 fn test_validate_document_relative_path_not_found_in_dirs() {
1285 let filename = "rustledger_test_889_nonexistent.pdf";
1287 let dir = tempfile::tempdir().unwrap();
1288 let doc_subdir = dir.path().join("documents");
1289 std::fs::create_dir_all(&doc_subdir).unwrap();
1290
1291 let directives = vec![
1292 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1293 Directive::Document(Document {
1294 date: date(2024, 1, 15),
1295 account: "Assets:Bank".into(),
1296 path: filename.to_string(),
1297 tags: vec![],
1298 links: vec![],
1299 meta: Default::default(),
1300 }),
1301 ];
1302
1303 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1304 let errors = validate_with_options(&directives, options);
1305 assert!(
1306 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1307 "Should error when file not found in any document_dir"
1308 );
1309 }
1310
1311 #[test]
1312 fn test_validate_document_absolute_path_ignores_document_dirs() {
1313 let filename = "rustledger_test_889_absolute_receipt.pdf";
1314 let dir = tempfile::tempdir().unwrap();
1315 let doc_subdir = dir.path().join("documents");
1316 std::fs::create_dir_all(&doc_subdir).unwrap();
1317 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1318
1319 let directives = vec![
1320 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1321 Directive::Document(Document {
1322 date: date(2024, 1, 15),
1323 account: "Assets:Bank".into(),
1324 path: doc_subdir.join(filename).display().to_string(),
1325 tags: vec![],
1326 links: vec![],
1327 meta: Default::default(),
1328 }),
1329 ];
1330
1331 let options = ValidationOptions::default()
1333 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1334 let errors = validate_with_options(&directives, options);
1335 assert!(
1336 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1337 "Absolute path should work even with wrong document_dirs: {errors:?}"
1338 );
1339 }
1340
1341 #[test]
1350 fn test_validate_document_parallel_batch_check() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let doc_subdir = dir.path().join("docs");
1353 std::fs::create_dir_all(&doc_subdir).unwrap();
1354
1355 let mut directives: Vec<Directive> =
1358 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1359 for i in 0..100 {
1360 let filename = format!("receipt_{i}.pdf");
1361 if i % 2 == 0 {
1362 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1363 }
1364 directives.push(Directive::Document(Document {
1365 date: date(2024, 1, 15),
1366 account: "Assets:Bank".into(),
1367 path: filename,
1368 tags: vec![],
1369 links: vec![],
1370 meta: Default::default(),
1371 }));
1372 }
1373
1374 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1375 let errors = validate_with_options(&directives, options);
1376
1377 let not_found_count = errors
1378 .iter()
1379 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1380 .count();
1381 assert_eq!(
1382 not_found_count, 50,
1383 "exactly 50 of 100 documents should error as not-found"
1384 );
1385
1386 let example = errors
1390 .iter()
1391 .find(|e| e.code == ErrorCode::DocumentNotFound)
1392 .expect("should have at least one not-found error");
1393 assert!(
1394 example
1395 .context
1396 .as_deref()
1397 .is_some_and(|c| c.contains("searched")),
1398 "error context should mention the searched dirs, got: {:?}",
1399 example.context
1400 );
1401 }
1402
1403 #[test]
1404 fn test_error_code_is_warning() {
1405 assert!(!ErrorCode::AccountNotOpen.is_warning());
1406 assert!(!ErrorCode::DocumentNotFound.is_warning());
1407 assert!(ErrorCode::FutureDate.is_warning());
1408 }
1409
1410 #[test]
1411 fn test_validate_pad_basic() {
1412 let directives = vec![
1413 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1414 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1415 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1416 Directive::Balance(Balance::new(
1417 date(2024, 1, 2),
1418 "Assets:Bank",
1419 Amount::new(dec!(1000.00), "USD"),
1420 )),
1421 ];
1422
1423 let errors = validate(&directives);
1424 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1426 }
1427
1428 #[test]
1429 fn test_validate_pad_with_existing_balance() {
1430 let directives = vec![
1431 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1432 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1433 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1434 Directive::Transaction(
1436 Transaction::new(date(2024, 1, 5), "Initial deposit")
1437 .with_posting(Posting::new(
1438 "Assets:Bank",
1439 Amount::new(dec!(500.00), "USD"),
1440 ))
1441 .with_posting(Posting::new(
1442 "Income:Salary",
1443 Amount::new(dec!(-500.00), "USD"),
1444 )),
1445 ),
1446 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1448 Directive::Balance(Balance::new(
1449 date(2024, 1, 15),
1450 "Assets:Bank",
1451 Amount::new(dec!(1000.00), "USD"), )),
1453 ];
1454
1455 let errors = validate(&directives);
1456 assert!(
1458 errors.is_empty(),
1459 "Pad should add missing amount: {errors:?}"
1460 );
1461 }
1462
1463 #[test]
1464 fn test_validate_pad_account_not_open() {
1465 let directives = vec![
1466 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1467 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1469 ];
1470
1471 let errors = validate(&directives);
1472 assert!(
1473 errors
1474 .iter()
1475 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1476 "Should error for pad on unopened account"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_validate_pad_source_not_open() {
1482 let directives = vec![
1483 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1484 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1486 ];
1487
1488 let errors = validate(&directives);
1489 assert!(
1490 errors.iter().any(
1491 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1492 ),
1493 "Should error for pad with unopened source account"
1494 );
1495 }
1496
1497 #[test]
1498 fn test_validate_pad_negative_adjustment() {
1499 let directives = vec![
1501 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1502 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1503 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1504 Directive::Transaction(
1506 Transaction::new(date(2024, 1, 5), "Big deposit")
1507 .with_posting(Posting::new(
1508 "Assets:Bank",
1509 Amount::new(dec!(2000.00), "USD"),
1510 ))
1511 .with_posting(Posting::new(
1512 "Income:Salary",
1513 Amount::new(dec!(-2000.00), "USD"),
1514 )),
1515 ),
1516 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1518 Directive::Balance(Balance::new(
1519 date(2024, 1, 15),
1520 "Assets:Bank",
1521 Amount::new(dec!(1000.00), "USD"), )),
1523 ];
1524
1525 let errors = validate(&directives);
1526 assert!(
1527 errors.is_empty(),
1528 "Pad should handle negative adjustment: {errors:?}"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_validate_insufficient_units() {
1534 use rustledger_core::CostSpec;
1535
1536 let cost_spec = CostSpec::empty()
1537 .with_number_per(dec!(150))
1538 .with_currency("USD");
1539
1540 let directives = vec![
1541 Directive::Open(
1542 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1543 ),
1544 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1545 Directive::Transaction(
1547 Transaction::new(date(2024, 1, 15), "Buy")
1548 .with_posting(
1549 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1550 .with_cost(cost_spec.clone()),
1551 )
1552 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1553 ),
1554 Directive::Transaction(
1556 Transaction::new(date(2024, 6, 1), "Sell too many")
1557 .with_posting(
1558 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1559 .with_cost(cost_spec),
1560 )
1561 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1562 ),
1563 ];
1564
1565 let errors = validate(&directives);
1566 assert!(
1567 errors
1568 .iter()
1569 .any(|e| e.code == ErrorCode::InsufficientUnits),
1570 "Should error for insufficient units: {errors:?}"
1571 );
1572 }
1573
1574 #[test]
1575 fn test_validate_no_matching_lot() {
1576 use rustledger_core::CostSpec;
1577
1578 let directives = vec![
1579 Directive::Open(
1580 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1581 ),
1582 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1583 Directive::Transaction(
1585 Transaction::new(date(2024, 1, 15), "Buy")
1586 .with_posting(
1587 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1588 CostSpec::empty()
1589 .with_number_per(dec!(150))
1590 .with_currency("USD"),
1591 ),
1592 )
1593 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1594 ),
1595 Directive::Transaction(
1597 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1598 .with_posting(
1599 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1600 CostSpec::empty()
1601 .with_number_per(dec!(160))
1602 .with_currency("USD"),
1603 ),
1604 )
1605 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1606 ),
1607 ];
1608
1609 let errors = validate(&directives);
1610 assert!(
1611 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1612 "Should error for no matching lot: {errors:?}"
1613 );
1614 }
1615
1616 #[test]
1617 fn test_validate_multiple_lot_match_uses_fifo() {
1618 use rustledger_core::CostSpec;
1621
1622 let cost_spec = CostSpec::empty()
1623 .with_number_per(dec!(150))
1624 .with_currency("USD");
1625
1626 let directives = vec![
1627 Directive::Open(
1628 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1629 ),
1630 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1631 Directive::Transaction(
1633 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1634 .with_posting(
1635 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1636 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1637 )
1638 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1639 ),
1640 Directive::Transaction(
1642 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1643 .with_posting(
1644 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1645 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1646 )
1647 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1648 ),
1649 Directive::Transaction(
1651 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1652 .with_posting(
1653 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1654 .with_cost(cost_spec),
1655 )
1656 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1657 ),
1658 ];
1659
1660 let errors = validate(&directives);
1661 let booking_errors: Vec<_> = errors
1663 .iter()
1664 .filter(|e| {
1665 matches!(
1666 e.code,
1667 ErrorCode::InsufficientUnits
1668 | ErrorCode::NoMatchingLot
1669 | ErrorCode::AmbiguousLotMatch
1670 )
1671 })
1672 .collect();
1673 assert!(
1674 booking_errors.is_empty(),
1675 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_validate_successful_booking() {
1681 use rustledger_core::CostSpec;
1682
1683 let cost_spec = CostSpec::empty()
1684 .with_number_per(dec!(150))
1685 .with_currency("USD");
1686
1687 let directives = vec![
1688 Directive::Open(
1689 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1690 ),
1691 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1692 Directive::Transaction(
1694 Transaction::new(date(2024, 1, 15), "Buy")
1695 .with_posting(
1696 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1697 .with_cost(cost_spec.clone()),
1698 )
1699 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1700 ),
1701 Directive::Transaction(
1703 Transaction::new(date(2024, 6, 1), "Sell")
1704 .with_posting(
1705 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1706 .with_cost(cost_spec),
1707 )
1708 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1709 ),
1710 ];
1711
1712 let errors = validate(&directives);
1713 let booking_errors: Vec<_> = errors
1715 .iter()
1716 .filter(|e| {
1717 matches!(
1718 e.code,
1719 ErrorCode::InsufficientUnits
1720 | ErrorCode::NoMatchingLot
1721 | ErrorCode::AmbiguousLotMatch
1722 )
1723 })
1724 .collect();
1725 assert!(
1726 booking_errors.is_empty(),
1727 "Should have no booking errors: {booking_errors:?}"
1728 );
1729 }
1730
1731 #[test]
1732 fn test_validate_account_already_open() {
1733 let directives = vec![
1734 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1735 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1737
1738 let errors = validate(&directives);
1739 assert!(
1740 errors
1741 .iter()
1742 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1743 "Should error for duplicate open: {errors:?}"
1744 );
1745 }
1746
1747 #[test]
1748 fn test_validate_account_close_not_empty() {
1749 let directives = vec![
1750 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1751 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1752 Directive::Transaction(
1753 Transaction::new(date(2024, 1, 15), "Deposit")
1754 .with_posting(Posting::new(
1755 "Assets:Bank",
1756 Amount::new(dec!(100.00), "USD"),
1757 ))
1758 .with_posting(Posting::new(
1759 "Income:Salary",
1760 Amount::new(dec!(-100.00), "USD"),
1761 )),
1762 ),
1763 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1765
1766 let errors = validate(&directives);
1767 assert!(
1768 errors
1769 .iter()
1770 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1771 "Should warn for closing account with balance: {errors:?}"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_validate_no_postings_allowed() {
1777 let directives = vec![
1780 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1781 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1782 ];
1783
1784 let errors = validate(&directives);
1785 assert!(
1786 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1787 "Should NOT error for transaction with no postings: {errors:?}"
1788 );
1789 }
1790
1791 #[test]
1792 fn test_validate_single_posting() {
1793 let directives = vec![
1794 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1795 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1796 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1797 )),
1798 ];
1799
1800 let errors = validate(&directives);
1801 assert!(
1802 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1803 "Should warn for transaction with single posting: {errors:?}"
1804 );
1805 assert!(ErrorCode::SinglePosting.is_warning());
1807 }
1808
1809 #[test]
1810 fn test_validate_single_posting_zero_cost_no_warning() {
1811 let directives = vec![
1815 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1816 Directive::Transaction(
1817 Transaction::new(date(2024, 1, 15), "Grant").with_posting(
1818 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1819 rustledger_core::CostSpec::empty()
1820 .with_number_per(dec!(0))
1821 .with_currency("USD"),
1822 ),
1823 ),
1824 ),
1825 ];
1826
1827 let errors = validate(&directives);
1828 assert!(
1829 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1830 "Should NOT warn for zero-cost single posting: {errors:?}"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_validate_single_posting_nonzero_cost_still_warns() {
1836 let directives = vec![
1838 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1839 Directive::Transaction(
1840 Transaction::new(date(2024, 1, 15), "Buy").with_posting(
1841 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1842 rustledger_core::CostSpec::empty()
1843 .with_number_per(dec!(150))
1844 .with_currency("USD"),
1845 ),
1846 ),
1847 ),
1848 ];
1849
1850 let errors = validate(&directives);
1851 assert!(
1852 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1853 "Should warn for single posting with non-zero cost: {errors:?}"
1854 );
1855 }
1856
1857 #[test]
1858 fn test_validate_pad_without_balance() {
1859 let directives = vec![
1860 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1861 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1862 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1863 ];
1865
1866 let errors = validate(&directives);
1867 assert!(
1868 errors
1869 .iter()
1870 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1871 "Should error for pad without subsequent balance: {errors:?}"
1872 );
1873 }
1874
1875 #[test]
1876 fn test_validate_multiple_pads_for_balance() {
1877 let directives = vec![
1878 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1879 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1880 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1881 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1883 date(2024, 1, 3),
1884 "Assets:Bank",
1885 Amount::new(dec!(1000.00), "USD"),
1886 )),
1887 ];
1888
1889 let errors = validate(&directives);
1890 assert!(
1891 errors
1892 .iter()
1893 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1894 "Should error for multiple pads before balance: {errors:?}"
1895 );
1896 }
1897
1898 #[test]
1899 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
1900 let directives = vec![
1906 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1907 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1908 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1910 Directive::Balance(Balance::new(
1911 date(2024, 1, 2),
1912 "Assets:Bank",
1913 Amount::new(dec!(100.00), "USD"),
1914 )),
1915 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
1918 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
1919 Directive::Balance(Balance::new(
1920 date(2024, 2, 3),
1921 "Assets:Bank",
1922 Amount::new(dec!(200.00), "USD"),
1923 )),
1924 ];
1925
1926 let errors = validate(&directives);
1927 let multi_pad_count = errors
1928 .iter()
1929 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
1930 .count();
1931 assert_eq!(
1932 multi_pad_count, 1,
1933 "E2004 must fire exactly once on the second balance; got {errors:?}"
1934 );
1935 }
1936
1937 #[test]
1938 fn test_pad_serves_multi_currency_balances_on_same_day() {
1939 let directives = vec![
1946 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1947 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1948 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1949 Directive::Balance(Balance::new(
1951 date(2024, 1, 2),
1952 "Assets:Bank",
1953 Amount::new(dec!(100.00), "USD"),
1954 )),
1955 Directive::Balance(Balance::new(
1956 date(2024, 1, 2),
1957 "Assets:Bank",
1958 Amount::new(dec!(50.00), "EUR"),
1959 )),
1960 ];
1961
1962 let errors = validate(&directives);
1963 assert!(
1964 !errors
1965 .iter()
1966 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1967 "pad should serve both USD and EUR; got {errors:?}"
1968 );
1969 assert!(
1970 !errors
1971 .iter()
1972 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1973 "pad serves at least one balance; should not be E2003; got {errors:?}"
1974 );
1975 }
1976
1977 #[test]
1978 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
1979 let directives = vec![
1984 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1985 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1986 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
1987 Directive::Balance(Balance::new(
1988 date(2024, 1, 2),
1989 "Assets:Bank",
1990 Amount::new(dec!(100.00), "USD"),
1991 )),
1992 ];
1993
1994 let errors = validate(&directives);
1995 assert!(
1999 errors
2000 .iter()
2001 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2002 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2003 );
2004 assert!(
2006 errors
2007 .iter()
2008 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2009 "same-day pad never consumed; expected E2003; got {errors:?}"
2010 );
2011 }
2012
2013 #[test]
2014 fn test_future_pad_does_not_apply_to_earlier_balance() {
2015 let directives = vec![
2021 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2022 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2023 Directive::Balance(Balance::new(
2024 date(2024, 1, 2),
2025 "Assets:Bank",
2026 Amount::new(dec!(0.00), "USD"),
2027 )),
2028 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2029 ];
2030
2031 let errors = validate(&directives);
2032 assert!(
2035 !errors
2036 .iter()
2037 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2038 "future pad should not influence earlier balance; got {errors:?}"
2039 );
2040 assert!(
2042 errors
2043 .iter()
2044 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2045 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2046 );
2047 }
2048
2049 #[test]
2050 fn test_error_severity() {
2051 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2053 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2054 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2055
2056 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2058 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2059 assert_eq!(
2060 ErrorCode::AccountCloseNotEmpty.severity(),
2061 Severity::Warning
2062 );
2063
2064 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2066 }
2067
2068 #[test]
2069 fn test_validate_invalid_account_name() {
2070 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2072
2073 let errors = validate(&directives);
2074 assert!(
2075 errors
2076 .iter()
2077 .any(|e| e.code == ErrorCode::InvalidAccountName),
2078 "Should error for invalid account root: {errors:?}"
2079 );
2080 }
2081
2082 #[test]
2083 fn test_validate_account_lowercase_component() {
2084 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2086
2087 let errors = validate(&directives);
2088 assert!(
2089 errors
2090 .iter()
2091 .any(|e| e.code == ErrorCode::InvalidAccountName),
2092 "Should error for lowercase component: {errors:?}"
2093 );
2094 }
2095
2096 #[test]
2097 fn test_validate_valid_account_names() {
2098 let valid_names = [
2100 "Assets:Bank",
2101 "Assets:Bank:Checking",
2102 "Liabilities:CreditCard",
2103 "Equity:Opening-Balances",
2104 "Income:Salary2024",
2105 "Expenses:Food:Restaurant",
2106 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2112
2113 for name in valid_names {
2114 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2115
2116 let errors = validate(&directives);
2117 let name_errors: Vec<_> = errors
2118 .iter()
2119 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2120 .collect();
2121 assert!(
2122 name_errors.is_empty(),
2123 "Should accept valid account name '{name}': {name_errors:?}"
2124 );
2125 }
2126 }
2127
2128 #[test]
2133 fn test_e2002_balance_exceeds_explicit_tolerance() {
2134 let directives = vec![
2137 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2138 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2139 Directive::Transaction(
2140 Transaction::new(date(2024, 1, 15), "Deposit")
2141 .with_posting(Posting::new(
2142 "Assets:Bank",
2143 Amount::new(dec!(1000.00), "USD"),
2144 ))
2145 .with_posting(Posting::new(
2146 "Income:Salary",
2147 Amount::new(dec!(-1000.00), "USD"),
2148 )),
2149 ),
2150 Directive::Balance(
2153 Balance::new(
2154 date(2024, 1, 16),
2155 "Assets:Bank",
2156 Amount::new(dec!(999.00), "USD"),
2157 )
2158 .with_tolerance(dec!(0.01)),
2159 ),
2160 ];
2161
2162 let errors = validate(&directives);
2163
2164 assert!(
2165 errors
2166 .iter()
2167 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2168 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2169 );
2170 }
2171
2172 #[test]
2173 fn test_e2002_balance_within_explicit_tolerance_passes() {
2174 let directives = vec![
2176 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2177 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2178 Directive::Transaction(
2179 Transaction::new(date(2024, 1, 15), "Deposit")
2180 .with_posting(Posting::new(
2181 "Assets:Bank",
2182 Amount::new(dec!(1000.00), "USD"),
2183 ))
2184 .with_posting(Posting::new(
2185 "Income:Salary",
2186 Amount::new(dec!(-1000.00), "USD"),
2187 )),
2188 ),
2189 Directive::Balance(
2191 Balance::new(
2192 date(2024, 1, 16),
2193 "Assets:Bank",
2194 Amount::new(dec!(999.00), "USD"),
2195 )
2196 .with_tolerance(dec!(5.00)),
2197 ),
2198 ];
2199
2200 let errors = validate(&directives);
2201
2202 assert!(
2203 !errors
2204 .iter()
2205 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2206 || e.code == ErrorCode::BalanceAssertionFailed),
2207 "Expected no balance errors, got: {errors:?}"
2208 );
2209 }
2210
2211 #[test]
2212 fn test_e5001_undeclared_currency() {
2213 use rustledger_core::Commodity;
2216
2217 let directives = vec![
2218 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2219 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2220 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2221 Directive::Transaction(
2222 Transaction::new(date(2024, 1, 15), "Lunch")
2223 .with_posting(Posting::new(
2224 "Expenses:Food",
2225 Amount::new(dec!(20.00), "EUR"), ))
2227 .with_posting(Posting::new(
2228 "Assets:Bank",
2229 Amount::new(dec!(-20.00), "EUR"),
2230 )),
2231 ),
2232 ];
2233
2234 let options = ValidationOptions::default().with_require_commodities(true);
2235 let errors = validate_with_options(&directives, options);
2236
2237 assert!(
2238 errors
2239 .iter()
2240 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2241 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2242 );
2243 }
2244
2245 #[test]
2246 fn test_e5001_declared_currency_passes() {
2247 use rustledger_core::Commodity;
2249
2250 let directives = vec![
2251 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2252 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2253 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2254 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2255 Directive::Transaction(
2256 Transaction::new(date(2024, 1, 15), "Lunch")
2257 .with_posting(Posting::new(
2258 "Expenses:Food",
2259 Amount::new(dec!(20.00), "EUR"),
2260 ))
2261 .with_posting(Posting::new(
2262 "Assets:Bank",
2263 Amount::new(dec!(-20.00), "EUR"),
2264 )),
2265 ),
2266 ];
2267
2268 let options = ValidationOptions::default().with_require_commodities(true);
2269 let errors = validate_with_options(&directives, options);
2270
2271 assert!(
2272 !errors
2273 .iter()
2274 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2275 "Expected no E5001 errors, got: {errors:?}"
2276 );
2277 }
2278
2279 #[test]
2280 fn test_e5001_not_raised_without_require_commodities() {
2281 let directives = vec![
2283 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2284 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2285 Directive::Transaction(
2286 Transaction::new(date(2024, 1, 15), "Lunch")
2287 .with_posting(Posting::new(
2288 "Expenses:Food",
2289 Amount::new(dec!(20.00), "XYZ"), ))
2291 .with_posting(Posting::new(
2292 "Assets:Bank",
2293 Amount::new(dec!(-20.00), "XYZ"),
2294 )),
2295 ),
2296 ];
2297
2298 let errors = validate(&directives);
2299
2300 assert!(
2301 !errors
2302 .iter()
2303 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2304 "Should not raise E5001 without require_commodities, got: {errors:?}"
2305 );
2306 }
2307
2308 #[test]
2309 fn test_e3002_multiple_missing_amounts() {
2310 let directives = vec![
2312 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2313 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2314 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2315 Directive::Transaction(
2316 Transaction::new(date(2024, 1, 15), "Lunch")
2317 .with_posting(Posting::new(
2318 "Assets:Bank",
2319 Amount::new(dec!(-50.00), "USD"),
2320 ))
2321 .with_posting(Posting {
2323 account: "Expenses:Food".into(),
2324 units: None,
2325 cost: None,
2326 price: None,
2327 flag: None,
2328 meta: Default::default(),
2329 comments: vec![],
2330 trailing_comments: vec![],
2331 })
2332 .with_posting(Posting {
2333 account: "Expenses:Drinks".into(),
2334 units: None,
2335 cost: None,
2336 price: None,
2337 flag: None,
2338 meta: Default::default(),
2339 comments: vec![],
2340 trailing_comments: vec![],
2341 }),
2342 ),
2343 ];
2344
2345 let errors = validate(&directives);
2346
2347 assert!(
2348 errors
2349 .iter()
2350 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2351 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2352 );
2353 }
2354
2355 #[test]
2356 fn test_e3002_single_missing_amount_ok() {
2357 let directives = vec![
2359 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2360 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2361 Directive::Transaction(
2362 Transaction::new(date(2024, 1, 15), "Lunch")
2363 .with_posting(Posting::new(
2364 "Assets:Bank",
2365 Amount::new(dec!(-50.00), "USD"),
2366 ))
2367 .with_posting(Posting {
2368 account: "Expenses:Food".into(),
2369 units: None,
2370 cost: None,
2371 price: None,
2372 flag: None,
2373 meta: Default::default(),
2374 comments: vec![],
2375 trailing_comments: vec![],
2376 }),
2377 ),
2378 ];
2379
2380 let errors = validate(&directives);
2381
2382 assert!(
2383 !errors
2384 .iter()
2385 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2386 "Should not raise E3002 with single missing amount, got: {errors:?}"
2387 );
2388 }
2389
2390 #[test]
2391 fn test_e7001_unknown_option() {
2392 let state = LedgerState::new();
2394 let mut errors = Vec::new();
2395
2396 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2397
2398 assert_eq!(errors.len(), 1);
2399 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2400 assert!(errors[0].message.contains("bogus_option"));
2401 }
2402
2403 #[test]
2404 fn test_e7002_invalid_option_value() {
2405 let state = LedgerState::new();
2406 let mut errors = Vec::new();
2407
2408 state.import_option_warnings(
2409 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2410 &mut errors,
2411 );
2412
2413 assert_eq!(errors.len(), 1);
2414 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2415 }
2416
2417 #[test]
2418 fn test_e7003_duplicate_option() {
2419 let state = LedgerState::new();
2420 let mut errors = Vec::new();
2421
2422 state.import_option_warnings(
2423 &[("E7003", "Option \"title\" can only be specified once")],
2424 &mut errors,
2425 );
2426
2427 assert_eq!(errors.len(), 1);
2428 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2429 }
2430
2431 fn commodity_with_precision(value: MetaValue) -> Directive {
2434 let mut meta = rustledger_core::Metadata::default();
2435 meta.insert("precision".into(), value);
2436 Directive::Commodity(
2437 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2438 )
2439 }
2440
2441 #[test]
2442 fn precision_meta_valid_integer_emits_no_warning() {
2443 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2444 let errors = validate(&directives);
2445 assert!(
2446 errors
2447 .iter()
2448 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2449 "valid precision must not produce a warning, got: {errors:?}"
2450 );
2451 }
2452
2453 #[test]
2454 fn precision_meta_zero_is_valid() {
2455 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2456 let errors = validate(&directives);
2457 assert!(
2458 errors
2459 .iter()
2460 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2461 );
2462 }
2463
2464 #[test]
2465 fn precision_meta_negative_emits_e5003() {
2466 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2467 let errors = validate(&directives);
2468 let warnings: Vec<_> = errors
2469 .iter()
2470 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2471 .collect();
2472 assert_eq!(warnings.len(), 1, "expected one E5003");
2473 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2474 assert!(warnings[0].message.contains("non-negative"));
2475 }
2476
2477 #[test]
2478 fn precision_meta_non_integer_emits_e5003() {
2479 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2480 let errors = validate(&directives);
2481 let warnings: Vec<_> = errors
2482 .iter()
2483 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2484 .collect();
2485 assert_eq!(warnings.len(), 1);
2486 assert!(warnings[0].message.contains("integer"));
2487 }
2488
2489 #[test]
2490 fn precision_meta_string_value_emits_e5003() {
2491 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2492 let errors = validate(&directives);
2493 let warnings: Vec<_> = errors
2494 .iter()
2495 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2496 .collect();
2497 assert_eq!(warnings.len(), 1);
2498 assert!(warnings[0].message.contains("string"));
2499 }
2500
2501 #[test]
2502 fn precision_meta_out_of_u32_range_emits_e5003() {
2503 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2505 8589934592
2506 )))];
2507 let errors = validate(&directives);
2508 let warnings: Vec<_> = errors
2509 .iter()
2510 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2511 .collect();
2512 assert_eq!(warnings.len(), 1);
2513 assert!(warnings[0].message.contains("exceeds"));
2514 }
2515
2516 #[test]
2517 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2518 let directives = vec![
2523 commodity_with_precision(MetaValue::Number(dec!(2))),
2524 commodity_with_precision(MetaValue::Number(dec!(-1))),
2525 ];
2526 let warnings: Vec<_> = validate(&directives)
2527 .into_iter()
2528 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2529 .collect();
2530 assert_eq!(
2531 warnings.len(),
2532 1,
2533 "exactly one E5003 expected (only the invalid declaration)"
2534 );
2535 assert!(warnings[0].message.contains("non-negative"));
2536 }
2537
2538 #[test]
2539 fn precision_meta_e5003_is_warning_severity() {
2540 assert_eq!(
2544 ErrorCode::InvalidPrecisionMetadata.severity(),
2545 Severity::Warning
2546 );
2547 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2548 }
2549
2550 #[test]
2558 fn test_validate_early_emits_e1001_on_elided_posting() {
2559 let directives = vec![
2560 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2561 Directive::Transaction(
2562 Transaction::new(date(2024, 1, 15), "Zero to unopened")
2563 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(0.00), "USD")))
2564 .with_posting(Posting::auto("Expenses:NeverOpened")),
2565 ),
2566 ];
2567
2568 let mut session = ValidationSession::new(ValidationOptions::default());
2569 let errors = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
2570
2571 assert!(
2572 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
2573 && e.to_string().contains("Expenses:NeverOpened")),
2574 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
2575 );
2576 }
2577
2578 #[test]
2582 fn test_validate_late_does_not_duplicate_e1001() {
2583 let directives = vec![
2584 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2585 Directive::Transaction(
2586 Transaction::new(date(2024, 1, 15), "To unopened")
2587 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
2588 .with_posting(Posting::new(
2589 "Expenses:NeverOpened",
2590 Amount::new(dec!(-100), "USD"),
2591 )),
2592 ),
2593 ];
2594
2595 let mut session = ValidationSession::new(ValidationOptions::default());
2596 let early = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
2597 let late = session.run_phase(&directives, Phase::Late, date(2026, 1, 1));
2598
2599 let early_e1001 = early
2600 .iter()
2601 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2602 .count();
2603 let late_e1001 = late
2604 .iter()
2605 .filter(|e| e.code == ErrorCode::AccountNotOpen)
2606 .count();
2607
2608 assert_eq!(early_e1001, 1, "early phase should emit E1001 once");
2609 assert_eq!(
2610 late_e1001, 0,
2611 "late phase must not re-emit account-presence errors; got: {late:?}"
2612 );
2613 }
2614
2615 #[test]
2621 fn test_validate_chained_matches_explicit_phases() {
2622 let directives = vec![
2626 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2627 Directive::Transaction(
2628 Transaction::new(date(2024, 1, 15), "Mixed")
2629 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(50), "USD")))
2630 .with_posting(Posting::new("Income:Salary", Amount::new(dec!(-50), "USD"))),
2631 ),
2632 Directive::Balance(Balance::new(
2633 date(2024, 1, 16),
2634 "Assets:Bank",
2635 Amount::new(dec!(50), "USD"),
2636 )),
2637 ];
2638
2639 let chained = validate(&directives);
2641
2642 let mut session = ValidationSession::new(ValidationOptions::default());
2644 let mut explicit = session.run_phase(&directives, Phase::Early, date(2026, 1, 1));
2645 explicit.extend(session.run_phase(&directives, Phase::Late, date(2026, 1, 1)));
2646 explicit.extend(session.finalize());
2647
2648 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
2652 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
2653 assert_eq!(
2654 chained_strs, explicit_strs,
2655 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
2656 );
2657 }
2658
2659 #[test]
2660 fn test_phase_order_early_then_late_then_finalize() {
2661 let directives = vec![
2668 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2669 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
2670 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2671 Directive::Transaction(
2673 Transaction::new(date(2024, 1, 5), "early")
2674 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
2675 .with_posting(Posting::new(
2676 "Income:Salary",
2677 Amount::new(dec!(-100), "USD"),
2678 )),
2679 ),
2680 Directive::Pad(Pad::new(
2682 date(2024, 1, 10),
2683 "Assets:Other",
2684 "Equity:Opening",
2685 )),
2686 Directive::Balance(Balance::new(
2688 date(2024, 2, 1),
2689 "Assets:Bank",
2690 Amount::new(dec!(999), "USD"),
2691 )),
2692 ];
2693
2694 let errors = validate(&directives);
2695 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
2696
2697 let early_pos = codes
2698 .iter()
2699 .position(|c| *c == ErrorCode::AccountNotOpen)
2700 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
2701 let late_pos = codes
2702 .iter()
2703 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
2704 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
2705 let finalize_pos = codes
2706 .iter()
2707 .position(|c| *c == ErrorCode::PadWithoutBalance)
2708 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
2709
2710 assert!(
2711 early_pos < late_pos,
2712 "early-phase errors must precede late-phase; got {codes:?}"
2713 );
2714 assert!(
2715 late_pos < finalize_pos,
2716 "late-phase errors must precede finalize; got {codes:?}"
2717 );
2718 }
2719
2720 #[test]
2721 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
2722 let directives = vec![
2729 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2730 Directive::Transaction(
2733 Transaction::new(date(2024, 1, 10), "leave residue")
2734 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(50), "USD")))
2735 .with_posting(Posting::new(
2736 "Equity:Opening",
2737 Amount::new(dec!(-50), "USD"),
2738 )),
2739 ),
2740 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2741 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
2742 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
2743 ];
2744
2745 let errors = validate(&directives);
2746 let close_not_empty_count = errors
2747 .iter()
2748 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
2749 .count();
2750 assert_eq!(
2751 close_not_empty_count, 1,
2752 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
2753 );
2754 let account_closed_count = errors
2756 .iter()
2757 .filter(|e| e.code == ErrorCode::AccountClosed)
2758 .count();
2759 assert_eq!(
2760 account_closed_count, 1,
2761 "duplicate close should still report AccountClosed once; got {errors:?}"
2762 );
2763 }
2764
2765 #[cfg(debug_assertions)]
2774 #[test]
2775 #[should_panic(expected = "called twice for Late")]
2776 fn test_run_phase_duplicate_late_panics_in_debug() {
2777 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
2778 let mut session = ValidationSession::new(ValidationOptions::default());
2779 let _ = session.run_phase(&directives, Phase::Early, date(2030, 1, 1));
2780 let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
2781 let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
2783 }
2784
2785 #[cfg(debug_assertions)]
2786 #[test]
2787 #[should_panic(expected = "Phase::Late) called before Phase::Early")]
2788 fn test_run_phase_late_before_early_panics_in_debug() {
2789 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
2790 let mut session = ValidationSession::new(ValidationOptions::default());
2791 let _ = session.run_phase(&directives, Phase::Late, date(2030, 1, 1));
2792 }
2793}