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