1#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47use chrono::{Local, NaiveDate};
48use rayon::prelude::*;
49use rust_decimal::Decimal;
50use rustledger_core::{
51 Amount, Balance, BookingMethod, Close, Directive, Document, InternedStr, Inventory, Open, Pad,
52 Position, Posting, Transaction,
53};
54use std::collections::{HashMap, HashSet};
55use std::path::Path;
56use thiserror::Error;
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
62pub enum ErrorCode {
63 AccountNotOpen,
66 AccountAlreadyOpen,
68 AccountClosed,
70 AccountCloseNotEmpty,
72 InvalidAccountName,
74
75 BalanceAssertionFailed,
78 BalanceToleranceExceeded,
80 PadWithoutBalance,
82 MultiplePadForBalance,
84
85 TransactionUnbalanced,
88 MultipleInterpolation,
90 NoPostings,
92 SinglePosting,
94
95 NoMatchingLot,
98 InsufficientUnits,
100 AmbiguousLotMatch,
102 NegativeInventory,
104
105 UndeclaredCurrency,
108 CurrencyNotAllowed,
110
111 DuplicateMetadataKey,
114 InvalidMetadataValue,
116
117 UnknownOption,
120 InvalidOptionValue,
122 DuplicateOption,
124
125 DocumentNotFound,
128
129 DateOutOfOrder,
132 FutureDate,
134}
135
136impl ErrorCode {
137 #[must_use]
139 pub const fn code(&self) -> &'static str {
140 match self {
141 Self::AccountNotOpen => "E1001",
143 Self::AccountAlreadyOpen => "E1002",
144 Self::AccountClosed => "E1003",
145 Self::AccountCloseNotEmpty => "E1004",
146 Self::InvalidAccountName => "E1005",
147 Self::BalanceAssertionFailed => "E2001",
149 Self::BalanceToleranceExceeded => "E2002",
150 Self::PadWithoutBalance => "E2003",
151 Self::MultiplePadForBalance => "E2004",
152 Self::TransactionUnbalanced => "E3001",
154 Self::MultipleInterpolation => "E3002",
155 Self::NoPostings => "E3003",
156 Self::SinglePosting => "E3004",
157 Self::NoMatchingLot => "E4001",
159 Self::InsufficientUnits => "E4002",
160 Self::AmbiguousLotMatch => "E4003",
161 Self::NegativeInventory => "E4004",
162 Self::UndeclaredCurrency => "E5001",
164 Self::CurrencyNotAllowed => "E5002",
165 Self::DuplicateMetadataKey => "E6001",
167 Self::InvalidMetadataValue => "E6002",
168 Self::UnknownOption => "E7001",
170 Self::InvalidOptionValue => "E7002",
171 Self::DuplicateOption => "E7003",
172 Self::DocumentNotFound => "E8001",
174 Self::DateOutOfOrder => "E10001",
176 Self::FutureDate => "E10002",
177 }
178 }
179
180 #[must_use]
182 pub const fn is_warning(&self) -> bool {
183 matches!(
184 self,
185 Self::FutureDate
186 | Self::SinglePosting
187 | Self::AccountCloseNotEmpty
188 | Self::DateOutOfOrder
189 )
190 }
191
192 #[must_use]
194 pub const fn is_info(&self) -> bool {
195 matches!(self, Self::DateOutOfOrder)
196 }
197
198 #[must_use]
200 pub const fn severity(&self) -> Severity {
201 if self.is_info() {
202 Severity::Info
203 } else if self.is_warning() {
204 Severity::Warning
205 } else {
206 Severity::Error
207 }
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
213pub enum Severity {
214 Error,
216 Warning,
218 Info,
220}
221
222impl std::fmt::Display for ErrorCode {
223 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
224 write!(f, "{}", self.code())
225 }
226}
227
228#[derive(Debug, Clone, Error)]
230#[error("[{code}] {message}")]
231pub struct ValidationError {
232 pub code: ErrorCode,
234 pub message: String,
236 pub date: NaiveDate,
238 pub context: Option<String>,
240}
241
242impl ValidationError {
243 #[must_use]
245 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
246 Self {
247 code,
248 message: message.into(),
249 date,
250 context: None,
251 }
252 }
253
254 #[must_use]
256 pub fn with_context(mut self, context: impl Into<String>) -> Self {
257 self.context = Some(context.into());
258 self
259 }
260}
261
262#[derive(Debug, Clone)]
264struct AccountState {
265 opened: NaiveDate,
267 closed: Option<NaiveDate>,
269 currencies: HashSet<InternedStr>,
271 #[allow(dead_code)]
273 booking: BookingMethod,
274}
275
276#[derive(Debug, Clone, Default)]
278pub struct ValidationOptions {
279 pub require_commodities: bool,
281 pub check_documents: bool,
283 pub warn_future_dates: bool,
285 pub document_base: Option<std::path::PathBuf>,
287}
288
289#[derive(Debug, Clone)]
291struct PendingPad {
292 source_account: InternedStr,
294 date: NaiveDate,
296 used: bool,
298}
299
300#[derive(Debug, Default)]
302pub struct LedgerState {
303 accounts: HashMap<InternedStr, AccountState>,
305 inventories: HashMap<InternedStr, Inventory>,
307 commodities: HashSet<InternedStr>,
309 pending_pads: HashMap<InternedStr, Vec<PendingPad>>,
311 options: ValidationOptions,
313 last_date: Option<NaiveDate>,
315}
316
317impl LedgerState {
318 #[must_use]
320 pub fn new() -> Self {
321 Self::default()
322 }
323
324 #[must_use]
326 pub fn with_options(options: ValidationOptions) -> Self {
327 Self {
328 options,
329 ..Default::default()
330 }
331 }
332
333 pub fn set_require_commodities(&mut self, require: bool) {
335 self.options.require_commodities = require;
336 }
337
338 pub fn set_check_documents(&mut self, check: bool) {
340 self.options.check_documents = check;
341 }
342
343 pub fn set_warn_future_dates(&mut self, warn: bool) {
345 self.options.warn_future_dates = warn;
346 }
347
348 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
350 self.options.document_base = Some(base.into());
351 }
352
353 #[must_use]
355 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
356 self.inventories.get(account)
357 }
358
359 pub fn accounts(&self) -> impl Iterator<Item = &str> {
361 self.accounts.keys().map(InternedStr::as_str)
362 }
363}
364
365pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
369 validate_with_options(directives, ValidationOptions::default())
370}
371
372pub fn validate_with_options(
376 directives: &[Directive],
377 options: ValidationOptions,
378) -> Vec<ValidationError> {
379 let mut state = LedgerState::with_options(options);
380 let mut errors = Vec::new();
381
382 let today = Local::now().date_naive();
383
384 let mut sorted: Vec<&Directive> = directives.iter().collect();
387 sorted.par_sort_by(|a, b| {
388 a.date()
389 .cmp(&b.date())
390 .then_with(|| a.priority().cmp(&b.priority()))
391 });
392
393 for directive in sorted {
394 let date = directive.date();
395
396 if let Some(last) = state.last_date {
398 if date < last {
399 errors.push(ValidationError::new(
400 ErrorCode::DateOutOfOrder,
401 format!("Directive date {date} is before previous directive {last}"),
402 date,
403 ));
404 }
405 }
406 state.last_date = Some(date);
407
408 if state.options.warn_future_dates && date > today {
410 errors.push(ValidationError::new(
411 ErrorCode::FutureDate,
412 format!("Entry dated in the future: {date}"),
413 date,
414 ));
415 }
416
417 match directive {
418 Directive::Open(open) => {
419 validate_open(&mut state, open, &mut errors);
420 }
421 Directive::Close(close) => {
422 validate_close(&mut state, close, &mut errors);
423 }
424 Directive::Transaction(txn) => {
425 validate_transaction(&mut state, txn, &mut errors);
426 }
427 Directive::Balance(bal) => {
428 validate_balance(&mut state, bal, &mut errors);
429 }
430 Directive::Commodity(comm) => {
431 state.commodities.insert(comm.currency.clone());
432 }
433 Directive::Pad(pad) => {
434 validate_pad(&mut state, pad, &mut errors);
435 }
436 Directive::Document(doc) => {
437 validate_document(&state, doc, &mut errors);
438 }
439 _ => {}
440 }
441 }
442
443 for (account, pads) in &state.pending_pads {
445 for pad in pads {
446 if !pad.used {
447 errors.push(
448 ValidationError::new(
449 ErrorCode::PadWithoutBalance,
450 format!("Pad directive for {account} has no subsequent balance assertion"),
451 pad.date,
452 )
453 .with_context(format!("source account: {}", pad.source_account)),
454 );
455 }
456 }
457 }
458
459 errors
460}
461
462const VALID_ACCOUNT_ROOTS: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
464
465fn validate_account_name(account: &str) -> Option<String> {
468 if account.is_empty() {
469 return Some("account name is empty".to_string());
470 }
471
472 let parts: Vec<&str> = account.split(':').collect();
473 if parts.is_empty() {
474 return Some("account name has no components".to_string());
475 }
476
477 let root = parts[0];
479 if !VALID_ACCOUNT_ROOTS.contains(&root) {
480 return Some(format!(
481 "account must start with one of: {}",
482 VALID_ACCOUNT_ROOTS.join(", ")
483 ));
484 }
485
486 for (i, part) in parts.iter().enumerate() {
488 if part.is_empty() {
489 return Some(format!("component {} is empty", i + 1));
490 }
491
492 let Some(first_char) = part.chars().next() else {
495 return Some(format!("component {} is empty", i + 1));
498 };
499 if !first_char.is_ascii_uppercase() && !first_char.is_ascii_digit() {
500 return Some(format!(
501 "component '{part}' must start with uppercase letter or digit"
502 ));
503 }
504
505 for c in part.chars().skip(1) {
507 if !c.is_ascii_alphanumeric() && c != '-' {
508 return Some(format!(
509 "component '{part}' contains invalid character '{c}'"
510 ));
511 }
512 }
513 }
514
515 None }
517
518fn validate_open(state: &mut LedgerState, open: &Open, errors: &mut Vec<ValidationError>) {
519 if let Some(reason) = validate_account_name(&open.account) {
521 errors.push(
522 ValidationError::new(
523 ErrorCode::InvalidAccountName,
524 format!("Invalid account name \"{}\": {}", open.account, reason),
525 open.date,
526 )
527 .with_context(open.account.to_string()),
528 );
529 }
531
532 if let Some(existing) = state.accounts.get(&open.account) {
534 errors.push(ValidationError::new(
535 ErrorCode::AccountAlreadyOpen,
536 format!(
537 "Account {} is already open (opened on {})",
538 open.account, existing.opened
539 ),
540 open.date,
541 ));
542 return;
543 }
544
545 let booking = open
546 .booking
547 .as_ref()
548 .and_then(|b| b.parse::<BookingMethod>().ok())
549 .unwrap_or_default();
550
551 state.accounts.insert(
552 open.account.clone(),
553 AccountState {
554 opened: open.date,
555 closed: None,
556 currencies: open.currencies.iter().cloned().collect(),
557 booking,
558 },
559 );
560
561 state
562 .inventories
563 .insert(open.account.clone(), Inventory::new());
564}
565
566fn validate_close(state: &mut LedgerState, close: &Close, errors: &mut Vec<ValidationError>) {
567 match state.accounts.get_mut(&close.account) {
568 Some(account_state) => {
569 if account_state.closed.is_some() {
570 errors.push(ValidationError::new(
571 ErrorCode::AccountClosed,
572 format!("Account {} already closed", close.account),
573 close.date,
574 ));
575 } else {
576 if let Some(inv) = state.inventories.get(&close.account) {
578 if !inv.is_empty() {
579 let positions: Vec<String> = inv
580 .positions()
581 .iter()
582 .map(|p| format!("{} {}", p.units.number, p.units.currency))
583 .collect();
584 errors.push(
585 ValidationError::new(
586 ErrorCode::AccountCloseNotEmpty,
587 format!(
588 "Cannot close account {} with non-zero balance",
589 close.account
590 ),
591 close.date,
592 )
593 .with_context(format!("balance: {}", positions.join(", "))),
594 );
595 }
596 }
597 account_state.closed = Some(close.date);
598 }
599 }
600 None => {
601 errors.push(ValidationError::new(
602 ErrorCode::AccountNotOpen,
603 format!("Account {} was never opened", close.account),
604 close.date,
605 ));
606 }
607 }
608}
609
610fn validate_transaction(
611 state: &mut LedgerState,
612 txn: &Transaction,
613 errors: &mut Vec<ValidationError>,
614) {
615 if !validate_transaction_structure(txn, errors) {
617 return; }
619
620 validate_posting_accounts(state, txn, errors);
622
623 validate_transaction_balance(txn, errors);
625
626 update_inventories(state, txn, errors);
628}
629
630fn validate_transaction_structure(txn: &Transaction, errors: &mut Vec<ValidationError>) -> bool {
633 if txn.postings.is_empty() {
634 errors.push(ValidationError::new(
635 ErrorCode::NoPostings,
636 "Transaction must have at least one posting".to_string(),
637 txn.date,
638 ));
639 return false;
640 }
641
642 if txn.postings.len() == 1 {
643 errors.push(ValidationError::new(
644 ErrorCode::SinglePosting,
645 "Transaction has only one posting".to_string(),
646 txn.date,
647 ));
648 }
650
651 true
652}
653
654fn validate_posting_accounts(
656 state: &LedgerState,
657 txn: &Transaction,
658 errors: &mut Vec<ValidationError>,
659) {
660 for posting in &txn.postings {
661 match state.accounts.get(&posting.account) {
662 Some(account_state) => {
663 validate_account_lifecycle(txn, posting, account_state, errors);
664 validate_posting_currency(state, txn, posting, account_state, errors);
665 }
666 None => {
667 errors.push(ValidationError::new(
668 ErrorCode::AccountNotOpen,
669 format!("Account {} was never opened", posting.account),
670 txn.date,
671 ));
672 }
673 }
674 }
675}
676
677fn validate_account_lifecycle(
679 txn: &Transaction,
680 posting: &Posting,
681 account_state: &AccountState,
682 errors: &mut Vec<ValidationError>,
683) {
684 if txn.date < account_state.opened {
685 errors.push(ValidationError::new(
686 ErrorCode::AccountNotOpen,
687 format!(
688 "Account {} used on {} but not opened until {}",
689 posting.account, txn.date, account_state.opened
690 ),
691 txn.date,
692 ));
693 }
694
695 if let Some(closed) = account_state.closed {
696 if txn.date >= closed {
697 errors.push(ValidationError::new(
698 ErrorCode::AccountClosed,
699 format!(
700 "Account {} used on {} but was closed on {}",
701 posting.account, txn.date, closed
702 ),
703 txn.date,
704 ));
705 }
706 }
707}
708
709fn validate_posting_currency(
711 state: &LedgerState,
712 txn: &Transaction,
713 posting: &Posting,
714 account_state: &AccountState,
715 errors: &mut Vec<ValidationError>,
716) {
717 let Some(units) = posting.amount() else {
718 return;
719 };
720
721 if !account_state.currencies.is_empty() && !account_state.currencies.contains(&units.currency) {
723 errors.push(ValidationError::new(
724 ErrorCode::CurrencyNotAllowed,
725 format!(
726 "Currency {} not allowed in account {}",
727 units.currency, posting.account
728 ),
729 txn.date,
730 ));
731 }
732
733 if state.options.require_commodities && !state.commodities.contains(&units.currency) {
735 errors.push(ValidationError::new(
736 ErrorCode::UndeclaredCurrency,
737 format!("Currency {} not declared", units.currency),
738 txn.date,
739 ));
740 }
741}
742
743fn validate_transaction_balance(txn: &Transaction, errors: &mut Vec<ValidationError>) {
745 let residuals = rustledger_booking::calculate_residual(txn);
746 for (currency, residual) in residuals {
747 if residual.abs() > Decimal::new(5, 3) {
749 errors.push(ValidationError::new(
750 ErrorCode::TransactionUnbalanced,
751 format!("Transaction does not balance: residual {residual} {currency}"),
752 txn.date,
753 ));
754 }
755 }
756}
757
758fn update_inventories(
760 state: &mut LedgerState,
761 txn: &Transaction,
762 errors: &mut Vec<ValidationError>,
763) {
764 for posting in &txn.postings {
765 let Some(units) = posting.amount() else {
766 continue;
767 };
768 let Some(inv) = state.inventories.get_mut(&posting.account) else {
769 continue;
770 };
771
772 let booking_method = state
773 .accounts
774 .get(&posting.account)
775 .map(|a| a.booking)
776 .unwrap_or_default();
777
778 let is_reduction = units.number.is_sign_negative() && posting.cost.is_some();
779
780 if is_reduction {
781 process_inventory_reduction(inv, posting, units, booking_method, txn, errors);
782 } else {
783 process_inventory_addition(inv, posting, units, txn);
784 }
785 }
786}
787
788fn process_inventory_reduction(
790 inv: &mut Inventory,
791 posting: &Posting,
792 units: &Amount,
793 booking_method: BookingMethod,
794 txn: &Transaction,
795 errors: &mut Vec<ValidationError>,
796) {
797 match inv.reduce(units, posting.cost.as_ref(), booking_method) {
798 Ok(_) => {}
799 Err(rustledger_core::BookingError::InsufficientUnits {
800 requested,
801 available,
802 ..
803 }) => {
804 errors.push(
805 ValidationError::new(
806 ErrorCode::InsufficientUnits,
807 format!(
808 "Insufficient units in {}: requested {}, available {}",
809 posting.account, requested, available
810 ),
811 txn.date,
812 )
813 .with_context(format!("currency: {}", units.currency)),
814 );
815 }
816 Err(rustledger_core::BookingError::NoMatchingLot { currency, .. }) => {
817 errors.push(
818 ValidationError::new(
819 ErrorCode::NoMatchingLot,
820 format!("No matching lot for {} in {}", currency, posting.account),
821 txn.date,
822 )
823 .with_context(format!("cost spec: {:?}", posting.cost)),
824 );
825 }
826 Err(rustledger_core::BookingError::AmbiguousMatch {
827 currency,
828 num_matches,
829 }) => {
830 errors.push(
831 ValidationError::new(
832 ErrorCode::AmbiguousLotMatch,
833 format!(
834 "Ambiguous lot match for {}: {} lots match in {}",
835 currency, num_matches, posting.account
836 ),
837 txn.date,
838 )
839 .with_context("Specify cost, date, or label to disambiguate".to_string()),
840 );
841 }
842 Err(rustledger_core::BookingError::CurrencyMismatch { .. }) => {
843 }
845 }
846}
847
848fn process_inventory_addition(
850 inv: &mut Inventory,
851 posting: &Posting,
852 units: &Amount,
853 txn: &Transaction,
854) {
855 let position = if let Some(cost_spec) = &posting.cost {
856 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
857 rustledger_core::Position::with_cost(units.clone(), cost)
858 } else {
859 rustledger_core::Position::simple(units.clone())
860 }
861 } else {
862 rustledger_core::Position::simple(units.clone())
863 };
864
865 inv.add(position);
866}
867
868fn validate_pad(state: &mut LedgerState, pad: &Pad, errors: &mut Vec<ValidationError>) {
869 if !state.accounts.contains_key(&pad.account) {
871 errors.push(ValidationError::new(
872 ErrorCode::AccountNotOpen,
873 format!("Pad target account {} was never opened", pad.account),
874 pad.date,
875 ));
876 return;
877 }
878
879 if !state.accounts.contains_key(&pad.source_account) {
881 errors.push(ValidationError::new(
882 ErrorCode::AccountNotOpen,
883 format!("Pad source account {} was never opened", pad.source_account),
884 pad.date,
885 ));
886 return;
887 }
888
889 let pending_pad = PendingPad {
891 source_account: pad.source_account.clone(),
892 date: pad.date,
893 used: false,
894 };
895 state
896 .pending_pads
897 .entry(pad.account.clone())
898 .or_default()
899 .push(pending_pad);
900}
901
902fn validate_balance(state: &mut LedgerState, bal: &Balance, errors: &mut Vec<ValidationError>) {
903 if !state.accounts.contains_key(&bal.account) {
905 errors.push(ValidationError::new(
906 ErrorCode::AccountNotOpen,
907 format!("Account {} was never opened", bal.account),
908 bal.date,
909 ));
910 return;
911 }
912
913 if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
916 if pending_pads.len() > 1 && !pending_pads.iter().any(|p| p.used) {
918 errors.push(
919 ValidationError::new(
920 ErrorCode::MultiplePadForBalance,
921 format!(
922 "Multiple pad directives for {} {} before balance assertion",
923 bal.account, bal.amount.currency
924 ),
925 bal.date,
926 )
927 .with_context(format!(
928 "pad dates: {}",
929 pending_pads
930 .iter()
931 .map(|p| p.date.to_string())
932 .collect::<Vec<_>>()
933 .join(", ")
934 )),
935 );
936 }
937
938 if let Some(pending_pad) = pending_pads.last_mut() {
940 if let Some(inv) = state.inventories.get(&bal.account) {
942 let actual = inv.units(&bal.amount.currency);
943 let expected = bal.amount.number;
944 let difference = expected - actual;
945
946 if difference != Decimal::ZERO {
947 if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
949 target_inv.add(Position::simple(Amount::new(
950 difference,
951 &bal.amount.currency,
952 )));
953 }
954
955 if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
957 {
958 source_inv.add(Position::simple(Amount::new(
959 -difference,
960 &bal.amount.currency,
961 )));
962 }
963 }
964 }
965 pending_pad.used = true;
967 }
968 return;
970 }
971
972 if let Some(inv) = state.inventories.get(&bal.account) {
974 let actual = inv.units(&bal.amount.currency);
975 let expected = bal.amount.number;
976 let difference = (actual - expected).abs();
977
978 let (tolerance, is_explicit) = if let Some(t) = bal.tolerance {
980 (t, true)
981 } else {
982 (bal.amount.inferred_tolerance(), false)
983 };
984
985 if difference > tolerance {
986 let error_code = if is_explicit {
988 ErrorCode::BalanceToleranceExceeded
989 } else {
990 ErrorCode::BalanceAssertionFailed
991 };
992
993 let message = if is_explicit {
994 format!(
995 "Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
996 bal.account, expected, bal.amount.currency, tolerance, actual, bal.amount.currency, difference
997 )
998 } else {
999 format!(
1000 "Balance assertion failed for {}: expected {} {}, got {} {}",
1001 bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1002 )
1003 };
1004
1005 errors.push(
1006 ValidationError::new(error_code, message, bal.date)
1007 .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1008 );
1009 }
1010 }
1011}
1012
1013fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1014 if !state.accounts.contains_key(&doc.account) {
1016 errors.push(ValidationError::new(
1017 ErrorCode::AccountNotOpen,
1018 format!("Account {} was never opened", doc.account),
1019 doc.date,
1020 ));
1021 }
1022
1023 if state.options.check_documents {
1025 let doc_path = Path::new(&doc.path);
1026
1027 let full_path = if doc_path.is_absolute() {
1028 doc_path.to_path_buf()
1029 } else if let Some(base) = &state.options.document_base {
1030 base.join(doc_path)
1031 } else {
1032 doc_path.to_path_buf()
1033 };
1034
1035 if !full_path.exists() {
1036 errors.push(
1037 ValidationError::new(
1038 ErrorCode::DocumentNotFound,
1039 format!("Document file not found: {}", doc.path),
1040 doc.date,
1041 )
1042 .with_context(format!("resolved path: {}", full_path.display())),
1043 );
1044 }
1045 }
1046}
1047
1048#[cfg(test)]
1049mod tests {
1050 use super::*;
1051 use rust_decimal_macros::dec;
1052 use rustledger_core::{Amount, NaiveDate, Posting};
1053
1054 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1055 NaiveDate::from_ymd_opt(year, month, day).unwrap()
1056 }
1057
1058 #[test]
1059 fn test_validate_account_lifecycle() {
1060 let directives = vec![
1061 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1062 Directive::Transaction(
1063 Transaction::new(date(2024, 1, 15), "Test")
1064 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1065 .with_posting(Posting::new(
1066 "Income:Salary",
1067 Amount::new(dec!(-100), "USD"),
1068 )),
1069 ),
1070 ];
1071
1072 let errors = validate(&directives);
1073
1074 assert!(errors
1076 .iter()
1077 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1078 }
1079
1080 #[test]
1081 fn test_validate_account_used_before_open() {
1082 let directives = vec![
1083 Directive::Transaction(
1084 Transaction::new(date(2024, 1, 1), "Test")
1085 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1086 .with_posting(Posting::new(
1087 "Income:Salary",
1088 Amount::new(dec!(-100), "USD"),
1089 )),
1090 ),
1091 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1092 ];
1093
1094 let errors = validate(&directives);
1095
1096 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1097 }
1098
1099 #[test]
1100 fn test_validate_account_used_after_close() {
1101 let directives = vec![
1102 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1103 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1104 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1105 Directive::Transaction(
1106 Transaction::new(date(2024, 7, 1), "Test")
1107 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1108 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1109 ),
1110 ];
1111
1112 let errors = validate(&directives);
1113
1114 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1115 }
1116
1117 #[test]
1118 fn test_validate_balance_assertion() {
1119 let directives = vec![
1120 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1121 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1122 Directive::Transaction(
1123 Transaction::new(date(2024, 1, 15), "Deposit")
1124 .with_posting(Posting::new(
1125 "Assets:Bank",
1126 Amount::new(dec!(1000.00), "USD"),
1127 ))
1128 .with_posting(Posting::new(
1129 "Income:Salary",
1130 Amount::new(dec!(-1000.00), "USD"),
1131 )),
1132 ),
1133 Directive::Balance(Balance::new(
1134 date(2024, 1, 16),
1135 "Assets:Bank",
1136 Amount::new(dec!(1000.00), "USD"),
1137 )),
1138 ];
1139
1140 let errors = validate(&directives);
1141 assert!(errors.is_empty(), "{errors:?}");
1142 }
1143
1144 #[test]
1145 fn test_validate_balance_assertion_failed() {
1146 let directives = vec![
1147 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1148 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1149 Directive::Transaction(
1150 Transaction::new(date(2024, 1, 15), "Deposit")
1151 .with_posting(Posting::new(
1152 "Assets:Bank",
1153 Amount::new(dec!(1000.00), "USD"),
1154 ))
1155 .with_posting(Posting::new(
1156 "Income:Salary",
1157 Amount::new(dec!(-1000.00), "USD"),
1158 )),
1159 ),
1160 Directive::Balance(Balance::new(
1161 date(2024, 1, 16),
1162 "Assets:Bank",
1163 Amount::new(dec!(500.00), "USD"), )),
1165 ];
1166
1167 let errors = validate(&directives);
1168 assert!(errors
1169 .iter()
1170 .any(|e| e.code == ErrorCode::BalanceAssertionFailed));
1171 }
1172
1173 #[test]
1174 fn test_validate_unbalanced_transaction() {
1175 let directives = vec![
1176 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1177 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1178 Directive::Transaction(
1179 Transaction::new(date(2024, 1, 15), "Unbalanced")
1180 .with_posting(Posting::new(
1181 "Assets:Bank",
1182 Amount::new(dec!(-50.00), "USD"),
1183 ))
1184 .with_posting(Posting::new(
1185 "Expenses:Food",
1186 Amount::new(dec!(40.00), "USD"),
1187 )), ),
1189 ];
1190
1191 let errors = validate(&directives);
1192 assert!(errors
1193 .iter()
1194 .any(|e| e.code == ErrorCode::TransactionUnbalanced));
1195 }
1196
1197 #[test]
1198 fn test_validate_currency_not_allowed() {
1199 let directives = vec![
1200 Directive::Open(
1201 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1202 ),
1203 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1204 Directive::Transaction(
1205 Transaction::new(date(2024, 1, 15), "Test")
1206 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
1208 "Income:Salary",
1209 Amount::new(dec!(-100.00), "EUR"),
1210 )),
1211 ),
1212 ];
1213
1214 let errors = validate(&directives);
1215 assert!(errors
1216 .iter()
1217 .any(|e| e.code == ErrorCode::CurrencyNotAllowed));
1218 }
1219
1220 #[test]
1221 fn test_validate_future_date_warning() {
1222 let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1224
1225 let directives = vec![Directive::Open(Open {
1226 date: future_date,
1227 account: "Assets:Bank".into(),
1228 currencies: vec![],
1229 booking: None,
1230 meta: Default::default(),
1231 })];
1232
1233 let errors = validate(&directives);
1235 assert!(
1236 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1237 "Should not warn about future dates by default"
1238 );
1239
1240 let options = ValidationOptions {
1242 warn_future_dates: true,
1243 ..Default::default()
1244 };
1245 let errors = validate_with_options(&directives, options);
1246 assert!(
1247 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1248 "Should warn about future dates when enabled"
1249 );
1250 }
1251
1252 #[test]
1253 fn test_validate_document_not_found() {
1254 let directives = vec![
1255 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1256 Directive::Document(Document {
1257 date: date(2024, 1, 15),
1258 account: "Assets:Bank".into(),
1259 path: "/nonexistent/path/to/document.pdf".to_string(),
1260 tags: vec![],
1261 links: vec![],
1262 meta: Default::default(),
1263 }),
1264 ];
1265
1266 let errors = validate(&directives);
1268 assert!(
1269 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1270 "Should not check documents by default"
1271 );
1272
1273 let options = ValidationOptions {
1275 check_documents: true,
1276 ..Default::default()
1277 };
1278 let errors = validate_with_options(&directives, options);
1279 assert!(
1280 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1281 "Should report missing document when enabled"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_validate_document_account_not_open() {
1287 let directives = vec![Directive::Document(Document {
1288 date: date(2024, 1, 15),
1289 account: "Assets:Unknown".into(),
1290 path: "receipt.pdf".to_string(),
1291 tags: vec![],
1292 links: vec![],
1293 meta: Default::default(),
1294 })];
1295
1296 let errors = validate(&directives);
1297 assert!(
1298 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1299 "Should error for document on unopened account"
1300 );
1301 }
1302
1303 #[test]
1304 fn test_error_code_is_warning() {
1305 assert!(!ErrorCode::AccountNotOpen.is_warning());
1306 assert!(!ErrorCode::DocumentNotFound.is_warning());
1307 assert!(ErrorCode::FutureDate.is_warning());
1308 }
1309
1310 #[test]
1311 fn test_validate_pad_basic() {
1312 let directives = vec![
1313 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1314 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1315 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1316 Directive::Balance(Balance::new(
1317 date(2024, 1, 2),
1318 "Assets:Bank",
1319 Amount::new(dec!(1000.00), "USD"),
1320 )),
1321 ];
1322
1323 let errors = validate(&directives);
1324 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1326 }
1327
1328 #[test]
1329 fn test_validate_pad_with_existing_balance() {
1330 let directives = vec![
1331 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1332 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1333 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1334 Directive::Transaction(
1336 Transaction::new(date(2024, 1, 5), "Initial deposit")
1337 .with_posting(Posting::new(
1338 "Assets:Bank",
1339 Amount::new(dec!(500.00), "USD"),
1340 ))
1341 .with_posting(Posting::new(
1342 "Income:Salary",
1343 Amount::new(dec!(-500.00), "USD"),
1344 )),
1345 ),
1346 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1348 Directive::Balance(Balance::new(
1349 date(2024, 1, 15),
1350 "Assets:Bank",
1351 Amount::new(dec!(1000.00), "USD"), )),
1353 ];
1354
1355 let errors = validate(&directives);
1356 assert!(
1358 errors.is_empty(),
1359 "Pad should add missing amount: {errors:?}"
1360 );
1361 }
1362
1363 #[test]
1364 fn test_validate_pad_account_not_open() {
1365 let directives = vec![
1366 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1367 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1369 ];
1370
1371 let errors = validate(&directives);
1372 assert!(
1373 errors
1374 .iter()
1375 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1376 "Should error for pad on unopened account"
1377 );
1378 }
1379
1380 #[test]
1381 fn test_validate_pad_source_not_open() {
1382 let directives = vec![
1383 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1384 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1386 ];
1387
1388 let errors = validate(&directives);
1389 assert!(
1390 errors.iter().any(
1391 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1392 ),
1393 "Should error for pad with unopened source account"
1394 );
1395 }
1396
1397 #[test]
1398 fn test_validate_pad_negative_adjustment() {
1399 let directives = vec![
1401 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1402 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1403 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1404 Directive::Transaction(
1406 Transaction::new(date(2024, 1, 5), "Big deposit")
1407 .with_posting(Posting::new(
1408 "Assets:Bank",
1409 Amount::new(dec!(2000.00), "USD"),
1410 ))
1411 .with_posting(Posting::new(
1412 "Income:Salary",
1413 Amount::new(dec!(-2000.00), "USD"),
1414 )),
1415 ),
1416 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1418 Directive::Balance(Balance::new(
1419 date(2024, 1, 15),
1420 "Assets:Bank",
1421 Amount::new(dec!(1000.00), "USD"), )),
1423 ];
1424
1425 let errors = validate(&directives);
1426 assert!(
1427 errors.is_empty(),
1428 "Pad should handle negative adjustment: {errors:?}"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_validate_insufficient_units() {
1434 use rustledger_core::CostSpec;
1435
1436 let cost_spec = CostSpec::empty()
1437 .with_number_per(dec!(150))
1438 .with_currency("USD");
1439
1440 let directives = vec![
1441 Directive::Open(
1442 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1443 ),
1444 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1445 Directive::Transaction(
1447 Transaction::new(date(2024, 1, 15), "Buy")
1448 .with_posting(
1449 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1450 .with_cost(cost_spec.clone()),
1451 )
1452 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1453 ),
1454 Directive::Transaction(
1456 Transaction::new(date(2024, 6, 1), "Sell too many")
1457 .with_posting(
1458 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1459 .with_cost(cost_spec),
1460 )
1461 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1462 ),
1463 ];
1464
1465 let errors = validate(&directives);
1466 assert!(
1467 errors
1468 .iter()
1469 .any(|e| e.code == ErrorCode::InsufficientUnits),
1470 "Should error for insufficient units: {errors:?}"
1471 );
1472 }
1473
1474 #[test]
1475 fn test_validate_no_matching_lot() {
1476 use rustledger_core::CostSpec;
1477
1478 let directives = vec![
1479 Directive::Open(
1480 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1481 ),
1482 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1483 Directive::Transaction(
1485 Transaction::new(date(2024, 1, 15), "Buy")
1486 .with_posting(
1487 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1488 CostSpec::empty()
1489 .with_number_per(dec!(150))
1490 .with_currency("USD"),
1491 ),
1492 )
1493 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1494 ),
1495 Directive::Transaction(
1497 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1498 .with_posting(
1499 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1500 CostSpec::empty()
1501 .with_number_per(dec!(160))
1502 .with_currency("USD"),
1503 ),
1504 )
1505 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1506 ),
1507 ];
1508
1509 let errors = validate(&directives);
1510 assert!(
1511 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1512 "Should error for no matching lot: {errors:?}"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_validate_ambiguous_lot_match() {
1518 use rustledger_core::CostSpec;
1519
1520 let cost_spec = CostSpec::empty()
1521 .with_number_per(dec!(150))
1522 .with_currency("USD");
1523
1524 let directives = vec![
1525 Directive::Open(
1526 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1527 ),
1528 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1529 Directive::Transaction(
1531 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1532 .with_posting(
1533 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1534 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1535 )
1536 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1537 ),
1538 Directive::Transaction(
1540 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1541 .with_posting(
1542 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1543 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1544 )
1545 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1546 ),
1547 Directive::Transaction(
1549 Transaction::new(date(2024, 6, 1), "Sell ambiguous")
1550 .with_posting(
1551 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1552 .with_cost(cost_spec),
1553 )
1554 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1555 ),
1556 ];
1557
1558 let errors = validate(&directives);
1559 assert!(
1560 errors
1561 .iter()
1562 .any(|e| e.code == ErrorCode::AmbiguousLotMatch),
1563 "Should error for ambiguous lot match: {errors:?}"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_validate_successful_booking() {
1569 use rustledger_core::CostSpec;
1570
1571 let cost_spec = CostSpec::empty()
1572 .with_number_per(dec!(150))
1573 .with_currency("USD");
1574
1575 let directives = vec![
1576 Directive::Open(
1577 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1578 ),
1579 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1580 Directive::Transaction(
1582 Transaction::new(date(2024, 1, 15), "Buy")
1583 .with_posting(
1584 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1585 .with_cost(cost_spec.clone()),
1586 )
1587 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1588 ),
1589 Directive::Transaction(
1591 Transaction::new(date(2024, 6, 1), "Sell")
1592 .with_posting(
1593 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1594 .with_cost(cost_spec),
1595 )
1596 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1597 ),
1598 ];
1599
1600 let errors = validate(&directives);
1601 let booking_errors: Vec<_> = errors
1603 .iter()
1604 .filter(|e| {
1605 matches!(
1606 e.code,
1607 ErrorCode::InsufficientUnits
1608 | ErrorCode::NoMatchingLot
1609 | ErrorCode::AmbiguousLotMatch
1610 )
1611 })
1612 .collect();
1613 assert!(
1614 booking_errors.is_empty(),
1615 "Should have no booking errors: {booking_errors:?}"
1616 );
1617 }
1618
1619 #[test]
1620 fn test_validate_account_already_open() {
1621 let directives = vec![
1622 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1623 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1625
1626 let errors = validate(&directives);
1627 assert!(
1628 errors
1629 .iter()
1630 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1631 "Should error for duplicate open: {errors:?}"
1632 );
1633 }
1634
1635 #[test]
1636 fn test_validate_account_close_not_empty() {
1637 let directives = vec![
1638 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1639 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1640 Directive::Transaction(
1641 Transaction::new(date(2024, 1, 15), "Deposit")
1642 .with_posting(Posting::new(
1643 "Assets:Bank",
1644 Amount::new(dec!(100.00), "USD"),
1645 ))
1646 .with_posting(Posting::new(
1647 "Income:Salary",
1648 Amount::new(dec!(-100.00), "USD"),
1649 )),
1650 ),
1651 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1653
1654 let errors = validate(&directives);
1655 assert!(
1656 errors
1657 .iter()
1658 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1659 "Should warn for closing account with balance: {errors:?}"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_validate_no_postings() {
1665 let directives = vec![
1666 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1667 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1668 ];
1669
1670 let errors = validate(&directives);
1671 assert!(
1672 errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1673 "Should error for transaction with no postings: {errors:?}"
1674 );
1675 }
1676
1677 #[test]
1678 fn test_validate_single_posting() {
1679 let directives = vec![
1680 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1681 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1682 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1683 )),
1684 ];
1685
1686 let errors = validate(&directives);
1687 assert!(
1688 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1689 "Should warn for transaction with single posting: {errors:?}"
1690 );
1691 assert!(ErrorCode::SinglePosting.is_warning());
1693 }
1694
1695 #[test]
1696 fn test_validate_pad_without_balance() {
1697 let directives = vec![
1698 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1699 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1700 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1701 ];
1703
1704 let errors = validate(&directives);
1705 assert!(
1706 errors
1707 .iter()
1708 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1709 "Should error for pad without subsequent balance: {errors:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_validate_multiple_pads_for_balance() {
1715 let directives = vec![
1716 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1717 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1718 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1719 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1721 date(2024, 1, 3),
1722 "Assets:Bank",
1723 Amount::new(dec!(1000.00), "USD"),
1724 )),
1725 ];
1726
1727 let errors = validate(&directives);
1728 assert!(
1729 errors
1730 .iter()
1731 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1732 "Should error for multiple pads before balance: {errors:?}"
1733 );
1734 }
1735
1736 #[test]
1737 fn test_error_severity() {
1738 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1740 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1741 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1742
1743 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1745 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1746 assert_eq!(
1747 ErrorCode::AccountCloseNotEmpty.severity(),
1748 Severity::Warning
1749 );
1750
1751 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1753 }
1754
1755 #[test]
1756 fn test_validate_invalid_account_name() {
1757 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1759
1760 let errors = validate(&directives);
1761 assert!(
1762 errors
1763 .iter()
1764 .any(|e| e.code == ErrorCode::InvalidAccountName),
1765 "Should error for invalid account root: {errors:?}"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_validate_account_lowercase_component() {
1771 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1773
1774 let errors = validate(&directives);
1775 assert!(
1776 errors
1777 .iter()
1778 .any(|e| e.code == ErrorCode::InvalidAccountName),
1779 "Should error for lowercase component: {errors:?}"
1780 );
1781 }
1782
1783 #[test]
1784 fn test_validate_valid_account_names() {
1785 let valid_names = [
1787 "Assets:Bank",
1788 "Assets:Bank:Checking",
1789 "Liabilities:CreditCard",
1790 "Equity:Opening-Balances",
1791 "Income:Salary2024",
1792 "Expenses:Food:Restaurant",
1793 "Assets:401k", ];
1795
1796 for name in valid_names {
1797 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1798
1799 let errors = validate(&directives);
1800 let name_errors: Vec<_> = errors
1801 .iter()
1802 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1803 .collect();
1804 assert!(
1805 name_errors.is_empty(),
1806 "Should accept valid account name '{name}': {name_errors:?}"
1807 );
1808 }
1809 }
1810}