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, expected, bal.amount.currency, tolerance, actual, bal.amount.currency, difference
998 )
999 } else {
1000 format!(
1001 "Balance assertion failed for {}: expected {} {}, got {} {}",
1002 bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1003 )
1004 };
1005
1006 errors.push(
1007 ValidationError::new(error_code, message, bal.date)
1008 .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1009 );
1010 }
1011 }
1012}
1013
1014fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1015 if !state.accounts.contains_key(&doc.account) {
1017 errors.push(ValidationError::new(
1018 ErrorCode::AccountNotOpen,
1019 format!("Account {} was never opened", doc.account),
1020 doc.date,
1021 ));
1022 }
1023
1024 if state.options.check_documents {
1026 let doc_path = Path::new(&doc.path);
1027
1028 let full_path = if doc_path.is_absolute() {
1029 doc_path.to_path_buf()
1030 } else if let Some(base) = &state.options.document_base {
1031 base.join(doc_path)
1032 } else {
1033 doc_path.to_path_buf()
1034 };
1035
1036 if !full_path.exists() {
1037 errors.push(
1038 ValidationError::new(
1039 ErrorCode::DocumentNotFound,
1040 format!("Document file not found: {}", doc.path),
1041 doc.date,
1042 )
1043 .with_context(format!("resolved path: {}", full_path.display())),
1044 );
1045 }
1046 }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052 use rust_decimal_macros::dec;
1053 use rustledger_core::{Amount, NaiveDate, Posting};
1054
1055 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1056 NaiveDate::from_ymd_opt(year, month, day).unwrap()
1057 }
1058
1059 #[test]
1060 fn test_validate_account_lifecycle() {
1061 let directives = vec![
1062 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1063 Directive::Transaction(
1064 Transaction::new(date(2024, 1, 15), "Test")
1065 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1066 .with_posting(Posting::new(
1067 "Income:Salary",
1068 Amount::new(dec!(-100), "USD"),
1069 )),
1070 ),
1071 ];
1072
1073 let errors = validate(&directives);
1074
1075 assert!(errors
1077 .iter()
1078 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1079 }
1080
1081 #[test]
1082 fn test_validate_account_used_before_open() {
1083 let directives = vec![
1084 Directive::Transaction(
1085 Transaction::new(date(2024, 1, 1), "Test")
1086 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1087 .with_posting(Posting::new(
1088 "Income:Salary",
1089 Amount::new(dec!(-100), "USD"),
1090 )),
1091 ),
1092 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1093 ];
1094
1095 let errors = validate(&directives);
1096
1097 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1098 }
1099
1100 #[test]
1101 fn test_validate_account_used_after_close() {
1102 let directives = vec![
1103 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1104 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1105 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1106 Directive::Transaction(
1107 Transaction::new(date(2024, 7, 1), "Test")
1108 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1109 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1110 ),
1111 ];
1112
1113 let errors = validate(&directives);
1114
1115 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1116 }
1117
1118 #[test]
1119 fn test_validate_balance_assertion() {
1120 let directives = vec![
1121 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1122 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1123 Directive::Transaction(
1124 Transaction::new(date(2024, 1, 15), "Deposit")
1125 .with_posting(Posting::new(
1126 "Assets:Bank",
1127 Amount::new(dec!(1000.00), "USD"),
1128 ))
1129 .with_posting(Posting::new(
1130 "Income:Salary",
1131 Amount::new(dec!(-1000.00), "USD"),
1132 )),
1133 ),
1134 Directive::Balance(Balance::new(
1135 date(2024, 1, 16),
1136 "Assets:Bank",
1137 Amount::new(dec!(1000.00), "USD"),
1138 )),
1139 ];
1140
1141 let errors = validate(&directives);
1142 assert!(errors.is_empty(), "{errors:?}");
1143 }
1144
1145 #[test]
1146 fn test_validate_balance_assertion_failed() {
1147 let directives = vec![
1148 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1149 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1150 Directive::Transaction(
1151 Transaction::new(date(2024, 1, 15), "Deposit")
1152 .with_posting(Posting::new(
1153 "Assets:Bank",
1154 Amount::new(dec!(1000.00), "USD"),
1155 ))
1156 .with_posting(Posting::new(
1157 "Income:Salary",
1158 Amount::new(dec!(-1000.00), "USD"),
1159 )),
1160 ),
1161 Directive::Balance(Balance::new(
1162 date(2024, 1, 16),
1163 "Assets:Bank",
1164 Amount::new(dec!(500.00), "USD"), )),
1166 ];
1167
1168 let errors = validate(&directives);
1169 assert!(errors
1170 .iter()
1171 .any(|e| e.code == ErrorCode::BalanceAssertionFailed));
1172 }
1173
1174 #[test]
1175 fn test_validate_unbalanced_transaction() {
1176 let directives = vec![
1177 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1178 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1179 Directive::Transaction(
1180 Transaction::new(date(2024, 1, 15), "Unbalanced")
1181 .with_posting(Posting::new(
1182 "Assets:Bank",
1183 Amount::new(dec!(-50.00), "USD"),
1184 ))
1185 .with_posting(Posting::new(
1186 "Expenses:Food",
1187 Amount::new(dec!(40.00), "USD"),
1188 )), ),
1190 ];
1191
1192 let errors = validate(&directives);
1193 assert!(errors
1194 .iter()
1195 .any(|e| e.code == ErrorCode::TransactionUnbalanced));
1196 }
1197
1198 #[test]
1199 fn test_validate_currency_not_allowed() {
1200 let directives = vec![
1201 Directive::Open(
1202 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1203 ),
1204 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1205 Directive::Transaction(
1206 Transaction::new(date(2024, 1, 15), "Test")
1207 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
1209 "Income:Salary",
1210 Amount::new(dec!(-100.00), "EUR"),
1211 )),
1212 ),
1213 ];
1214
1215 let errors = validate(&directives);
1216 assert!(errors
1217 .iter()
1218 .any(|e| e.code == ErrorCode::CurrencyNotAllowed));
1219 }
1220
1221 #[test]
1222 fn test_validate_future_date_warning() {
1223 let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1225
1226 let directives = vec![Directive::Open(Open {
1227 date: future_date,
1228 account: "Assets:Bank".into(),
1229 currencies: vec![],
1230 booking: None,
1231 meta: Default::default(),
1232 })];
1233
1234 let errors = validate(&directives);
1236 assert!(
1237 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1238 "Should not warn about future dates by default"
1239 );
1240
1241 let options = ValidationOptions {
1243 warn_future_dates: true,
1244 ..Default::default()
1245 };
1246 let errors = validate_with_options(&directives, options);
1247 assert!(
1248 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1249 "Should warn about future dates when enabled"
1250 );
1251 }
1252
1253 #[test]
1254 fn test_validate_document_not_found() {
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: "/nonexistent/path/to/document.pdf".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 not check documents by default"
1272 );
1273
1274 let options = ValidationOptions {
1276 check_documents: true,
1277 ..Default::default()
1278 };
1279 let errors = validate_with_options(&directives, options);
1280 assert!(
1281 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1282 "Should report missing document when enabled"
1283 );
1284 }
1285
1286 #[test]
1287 fn test_validate_document_account_not_open() {
1288 let directives = vec![Directive::Document(Document {
1289 date: date(2024, 1, 15),
1290 account: "Assets:Unknown".into(),
1291 path: "receipt.pdf".to_string(),
1292 tags: vec![],
1293 links: vec![],
1294 meta: Default::default(),
1295 })];
1296
1297 let errors = validate(&directives);
1298 assert!(
1299 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1300 "Should error for document on unopened account"
1301 );
1302 }
1303
1304 #[test]
1305 fn test_error_code_is_warning() {
1306 assert!(!ErrorCode::AccountNotOpen.is_warning());
1307 assert!(!ErrorCode::DocumentNotFound.is_warning());
1308 assert!(ErrorCode::FutureDate.is_warning());
1309 }
1310
1311 #[test]
1312 fn test_validate_pad_basic() {
1313 let directives = vec![
1314 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1315 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1316 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1317 Directive::Balance(Balance::new(
1318 date(2024, 1, 2),
1319 "Assets:Bank",
1320 Amount::new(dec!(1000.00), "USD"),
1321 )),
1322 ];
1323
1324 let errors = validate(&directives);
1325 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1327 }
1328
1329 #[test]
1330 fn test_validate_pad_with_existing_balance() {
1331 let directives = vec![
1332 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1333 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1334 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1335 Directive::Transaction(
1337 Transaction::new(date(2024, 1, 5), "Initial deposit")
1338 .with_posting(Posting::new(
1339 "Assets:Bank",
1340 Amount::new(dec!(500.00), "USD"),
1341 ))
1342 .with_posting(Posting::new(
1343 "Income:Salary",
1344 Amount::new(dec!(-500.00), "USD"),
1345 )),
1346 ),
1347 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1349 Directive::Balance(Balance::new(
1350 date(2024, 1, 15),
1351 "Assets:Bank",
1352 Amount::new(dec!(1000.00), "USD"), )),
1354 ];
1355
1356 let errors = validate(&directives);
1357 assert!(
1359 errors.is_empty(),
1360 "Pad should add missing amount: {errors:?}"
1361 );
1362 }
1363
1364 #[test]
1365 fn test_validate_pad_account_not_open() {
1366 let directives = vec![
1367 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1368 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1370 ];
1371
1372 let errors = validate(&directives);
1373 assert!(
1374 errors
1375 .iter()
1376 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1377 "Should error for pad on unopened account"
1378 );
1379 }
1380
1381 #[test]
1382 fn test_validate_pad_source_not_open() {
1383 let directives = vec![
1384 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1385 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1387 ];
1388
1389 let errors = validate(&directives);
1390 assert!(
1391 errors.iter().any(
1392 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1393 ),
1394 "Should error for pad with unopened source account"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_validate_pad_negative_adjustment() {
1400 let directives = vec![
1402 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1403 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1404 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1405 Directive::Transaction(
1407 Transaction::new(date(2024, 1, 5), "Big deposit")
1408 .with_posting(Posting::new(
1409 "Assets:Bank",
1410 Amount::new(dec!(2000.00), "USD"),
1411 ))
1412 .with_posting(Posting::new(
1413 "Income:Salary",
1414 Amount::new(dec!(-2000.00), "USD"),
1415 )),
1416 ),
1417 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1419 Directive::Balance(Balance::new(
1420 date(2024, 1, 15),
1421 "Assets:Bank",
1422 Amount::new(dec!(1000.00), "USD"), )),
1424 ];
1425
1426 let errors = validate(&directives);
1427 assert!(
1428 errors.is_empty(),
1429 "Pad should handle negative adjustment: {errors:?}"
1430 );
1431 }
1432
1433 #[test]
1434 fn test_validate_insufficient_units() {
1435 use rustledger_core::CostSpec;
1436
1437 let cost_spec = CostSpec::empty()
1438 .with_number_per(dec!(150))
1439 .with_currency("USD");
1440
1441 let directives = vec![
1442 Directive::Open(
1443 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1444 ),
1445 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1446 Directive::Transaction(
1448 Transaction::new(date(2024, 1, 15), "Buy")
1449 .with_posting(
1450 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1451 .with_cost(cost_spec.clone()),
1452 )
1453 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1454 ),
1455 Directive::Transaction(
1457 Transaction::new(date(2024, 6, 1), "Sell too many")
1458 .with_posting(
1459 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1460 .with_cost(cost_spec),
1461 )
1462 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1463 ),
1464 ];
1465
1466 let errors = validate(&directives);
1467 assert!(
1468 errors
1469 .iter()
1470 .any(|e| e.code == ErrorCode::InsufficientUnits),
1471 "Should error for insufficient units: {errors:?}"
1472 );
1473 }
1474
1475 #[test]
1476 fn test_validate_no_matching_lot() {
1477 use rustledger_core::CostSpec;
1478
1479 let directives = vec![
1480 Directive::Open(
1481 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1482 ),
1483 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1484 Directive::Transaction(
1486 Transaction::new(date(2024, 1, 15), "Buy")
1487 .with_posting(
1488 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1489 CostSpec::empty()
1490 .with_number_per(dec!(150))
1491 .with_currency("USD"),
1492 ),
1493 )
1494 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1495 ),
1496 Directive::Transaction(
1498 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1499 .with_posting(
1500 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1501 CostSpec::empty()
1502 .with_number_per(dec!(160))
1503 .with_currency("USD"),
1504 ),
1505 )
1506 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1507 ),
1508 ];
1509
1510 let errors = validate(&directives);
1511 assert!(
1512 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1513 "Should error for no matching lot: {errors:?}"
1514 );
1515 }
1516
1517 #[test]
1518 fn test_validate_ambiguous_lot_match() {
1519 use rustledger_core::CostSpec;
1520
1521 let cost_spec = CostSpec::empty()
1522 .with_number_per(dec!(150))
1523 .with_currency("USD");
1524
1525 let directives = vec![
1526 Directive::Open(
1527 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1528 ),
1529 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1530 Directive::Transaction(
1532 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1533 .with_posting(
1534 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1535 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1536 )
1537 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1538 ),
1539 Directive::Transaction(
1541 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1542 .with_posting(
1543 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1544 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1545 )
1546 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1547 ),
1548 Directive::Transaction(
1550 Transaction::new(date(2024, 6, 1), "Sell ambiguous")
1551 .with_posting(
1552 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1553 .with_cost(cost_spec),
1554 )
1555 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1556 ),
1557 ];
1558
1559 let errors = validate(&directives);
1560 assert!(
1561 errors
1562 .iter()
1563 .any(|e| e.code == ErrorCode::AmbiguousLotMatch),
1564 "Should error for ambiguous lot match: {errors:?}"
1565 );
1566 }
1567
1568 #[test]
1569 fn test_validate_successful_booking() {
1570 use rustledger_core::CostSpec;
1571
1572 let cost_spec = CostSpec::empty()
1573 .with_number_per(dec!(150))
1574 .with_currency("USD");
1575
1576 let directives = vec![
1577 Directive::Open(
1578 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1579 ),
1580 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1581 Directive::Transaction(
1583 Transaction::new(date(2024, 1, 15), "Buy")
1584 .with_posting(
1585 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1586 .with_cost(cost_spec.clone()),
1587 )
1588 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1589 ),
1590 Directive::Transaction(
1592 Transaction::new(date(2024, 6, 1), "Sell")
1593 .with_posting(
1594 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1595 .with_cost(cost_spec),
1596 )
1597 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1598 ),
1599 ];
1600
1601 let errors = validate(&directives);
1602 let booking_errors: Vec<_> = errors
1604 .iter()
1605 .filter(|e| {
1606 matches!(
1607 e.code,
1608 ErrorCode::InsufficientUnits
1609 | ErrorCode::NoMatchingLot
1610 | ErrorCode::AmbiguousLotMatch
1611 )
1612 })
1613 .collect();
1614 assert!(
1615 booking_errors.is_empty(),
1616 "Should have no booking errors: {booking_errors:?}"
1617 );
1618 }
1619
1620 #[test]
1621 fn test_validate_account_already_open() {
1622 let directives = vec![
1623 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1624 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1626
1627 let errors = validate(&directives);
1628 assert!(
1629 errors
1630 .iter()
1631 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1632 "Should error for duplicate open: {errors:?}"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_validate_account_close_not_empty() {
1638 let directives = vec![
1639 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1640 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1641 Directive::Transaction(
1642 Transaction::new(date(2024, 1, 15), "Deposit")
1643 .with_posting(Posting::new(
1644 "Assets:Bank",
1645 Amount::new(dec!(100.00), "USD"),
1646 ))
1647 .with_posting(Posting::new(
1648 "Income:Salary",
1649 Amount::new(dec!(-100.00), "USD"),
1650 )),
1651 ),
1652 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1654
1655 let errors = validate(&directives);
1656 assert!(
1657 errors
1658 .iter()
1659 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1660 "Should warn for closing account with balance: {errors:?}"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_validate_no_postings() {
1666 let directives = vec![
1667 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1668 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1669 ];
1670
1671 let errors = validate(&directives);
1672 assert!(
1673 errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1674 "Should error for transaction with no postings: {errors:?}"
1675 );
1676 }
1677
1678 #[test]
1679 fn test_validate_single_posting() {
1680 let directives = vec![
1681 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1682 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1683 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1684 )),
1685 ];
1686
1687 let errors = validate(&directives);
1688 assert!(
1689 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1690 "Should warn for transaction with single posting: {errors:?}"
1691 );
1692 assert!(ErrorCode::SinglePosting.is_warning());
1694 }
1695
1696 #[test]
1697 fn test_validate_pad_without_balance() {
1698 let directives = vec![
1699 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1700 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1701 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1702 ];
1704
1705 let errors = validate(&directives);
1706 assert!(
1707 errors
1708 .iter()
1709 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1710 "Should error for pad without subsequent balance: {errors:?}"
1711 );
1712 }
1713
1714 #[test]
1715 fn test_validate_multiple_pads_for_balance() {
1716 let directives = vec![
1717 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1718 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1719 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1720 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1722 date(2024, 1, 3),
1723 "Assets:Bank",
1724 Amount::new(dec!(1000.00), "USD"),
1725 )),
1726 ];
1727
1728 let errors = validate(&directives);
1729 assert!(
1730 errors
1731 .iter()
1732 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1733 "Should error for multiple pads before balance: {errors:?}"
1734 );
1735 }
1736
1737 #[test]
1738 fn test_error_severity() {
1739 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1741 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1742 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1743
1744 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1746 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1747 assert_eq!(
1748 ErrorCode::AccountCloseNotEmpty.severity(),
1749 Severity::Warning
1750 );
1751
1752 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1754 }
1755
1756 #[test]
1757 fn test_validate_invalid_account_name() {
1758 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1760
1761 let errors = validate(&directives);
1762 assert!(
1763 errors
1764 .iter()
1765 .any(|e| e.code == ErrorCode::InvalidAccountName),
1766 "Should error for invalid account root: {errors:?}"
1767 );
1768 }
1769
1770 #[test]
1771 fn test_validate_account_lowercase_component() {
1772 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1774
1775 let errors = validate(&directives);
1776 assert!(
1777 errors
1778 .iter()
1779 .any(|e| e.code == ErrorCode::InvalidAccountName),
1780 "Should error for lowercase component: {errors:?}"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_validate_valid_account_names() {
1786 let valid_names = [
1788 "Assets:Bank",
1789 "Assets:Bank:Checking",
1790 "Liabilities:CreditCard",
1791 "Equity:Opening-Balances",
1792 "Income:Salary2024",
1793 "Expenses:Food:Restaurant",
1794 "Assets:401k", ];
1796
1797 for name in valid_names {
1798 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1799
1800 let errors = validate(&directives);
1801 let name_errors: Vec<_> = errors
1802 .iter()
1803 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1804 .collect();
1805 assert!(
1806 name_errors.is_empty(),
1807 "Should accept valid account name '{name}': {name_errors:?}"
1808 );
1809 }
1810 }
1811}