1#![forbid(unsafe_code)]
44#![warn(missing_docs)]
45
46mod error;
47mod validators;
48
49pub use error::{ErrorCode, Severity, ValidationError};
50
51use validators::{
52 validate_balance, validate_close, validate_document, validate_note, validate_open,
53 validate_pad, validate_transaction,
54};
55
56use rayon::prelude::*;
57use rustledger_core::NaiveDate;
58
59const PARALLEL_SORT_THRESHOLD: usize = 5000;
62use rust_decimal::Decimal;
63use rustc_hash::{FxHashMap, FxHashSet};
64use rustledger_core::{BookingMethod, Directive, InternedStr, Inventory};
65use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
66
67#[derive(Debug, Clone)]
69struct AccountState {
70 opened: NaiveDate,
72 closed: Option<NaiveDate>,
74 currencies: FxHashSet<InternedStr>,
76 booking: BookingMethod,
79}
80
81#[non_exhaustive]
83#[derive(Debug, Clone)]
84pub struct ValidationOptions {
85 pub require_commodities: bool,
87 pub check_documents: bool,
89 pub warn_future_dates: bool,
91 pub document_base: Option<std::path::PathBuf>,
93 pub document_dirs: Vec<std::path::PathBuf>,
97 pub account_types: Vec<String>,
100 pub infer_tolerance_from_cost: bool,
103 pub tolerance_multiplier: Decimal,
106 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
109}
110
111impl Default for ValidationOptions {
112 fn default() -> Self {
113 Self {
114 require_commodities: false,
115 check_documents: true, warn_future_dates: false,
117 document_base: None,
118 document_dirs: Vec::new(),
119 account_types: vec![
120 "Assets".to_string(),
121 "Liabilities".to_string(),
122 "Equity".to_string(),
123 "Income".to_string(),
124 "Expenses".to_string(),
125 ],
126 infer_tolerance_from_cost: false,
128 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
130 }
131 }
132}
133
134impl ValidationOptions {
135 #[must_use]
137 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
138 self.account_types = types;
139 self
140 }
141
142 #[must_use]
144 pub const fn with_require_commodities(mut self, require: bool) -> Self {
145 self.require_commodities = require;
146 self
147 }
148
149 #[must_use]
151 pub const fn with_check_documents(mut self, check: bool) -> Self {
152 self.check_documents = check;
153 self
154 }
155
156 #[must_use]
158 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
159 self.warn_future_dates = warn;
160 self
161 }
162
163 #[must_use]
165 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
166 self.document_dirs = dirs;
167 self
168 }
169
170 #[must_use]
172 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
173 self.infer_tolerance_from_cost = infer;
174 self
175 }
176
177 #[must_use]
179 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
180 self.tolerance_multiplier = multiplier;
181 self
182 }
183
184 #[must_use]
186 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
187 self.inferred_tolerance_default = defaults;
188 self
189 }
190}
191
192#[derive(Debug, Clone)]
194struct PendingPad {
195 source_account: InternedStr,
197 date: NaiveDate,
199 used: bool,
201}
202
203#[derive(Debug, Default)]
205pub struct LedgerState {
206 accounts: FxHashMap<InternedStr, AccountState>,
208 inventories: FxHashMap<InternedStr, Inventory>,
210 commodities: FxHashSet<InternedStr>,
212 pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
214 options: ValidationOptions,
216 last_date: Option<NaiveDate>,
218 tolerances: FxHashMap<InternedStr, Decimal>,
221}
222
223impl LedgerState {
224 #[must_use]
226 pub fn new() -> Self {
227 Self::default()
228 }
229
230 #[must_use]
232 pub fn with_options(options: ValidationOptions) -> Self {
233 Self {
234 options,
235 ..Default::default()
236 }
237 }
238
239 pub const fn set_require_commodities(&mut self, require: bool) {
241 self.options.require_commodities = require;
242 }
243
244 pub const fn set_check_documents(&mut self, check: bool) {
246 self.options.check_documents = check;
247 }
248
249 pub const fn set_warn_future_dates(&mut self, warn: bool) {
251 self.options.warn_future_dates = warn;
252 }
253
254 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
256 self.options.document_base = Some(base.into());
257 }
258
259 #[must_use]
261 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
262 self.inventories.get(account)
263 }
264
265 pub fn accounts(&self) -> impl Iterator<Item = &str> {
267 self.accounts.keys().map(InternedStr::as_str)
268 }
269
270 pub fn import_option_warnings(
278 &self,
279 warnings: &[(&str, &str)],
280 errors: &mut Vec<ValidationError>,
281 ) {
282 for &(code, message) in warnings {
283 let error_code = match code {
284 "E7001" => ErrorCode::UnknownOption,
285 "E7002" => ErrorCode::InvalidOptionValue,
286 "E7003" => ErrorCode::DuplicateOption,
287 _ => continue,
288 };
289 errors.push(ValidationError::new(
290 error_code,
291 message.to_string(),
292 NaiveDate::default(),
294 ));
295 }
296 }
297}
298
299pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
303 validate_with_options(directives, ValidationOptions::default())
304}
305
306pub fn validate_with_options(
310 directives: &[Directive],
311 options: ValidationOptions,
312) -> Vec<ValidationError> {
313 let mut state = LedgerState::with_options(options);
314 let mut errors = Vec::new();
315
316 let today = jiff::Zoned::now().date();
317
318 let mut sorted: Vec<&Directive> = Vec::with_capacity(directives.len());
322 sorted.extend(directives.iter());
323 let sort_fn = |a: &&Directive, b: &&Directive| {
324 a.date()
325 .cmp(&b.date())
326 .then_with(|| a.priority().cmp(&b.priority()))
327 .then_with(|| a.has_cost_reduction().cmp(&b.has_cost_reduction()))
328 };
329 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
330 sorted.par_sort_by(sort_fn);
331 } else {
332 sorted.sort_by(sort_fn);
333 }
334
335 for directive in sorted {
336 let date = directive.date();
337
338 if let Some(last) = state.last_date
340 && date < last
341 {
342 errors.push(ValidationError::new(
343 ErrorCode::DateOutOfOrder,
344 format!("Directive date {date} is before previous directive {last}"),
345 date,
346 ));
347 }
348 state.last_date = Some(date);
349
350 if state.options.warn_future_dates && date > today {
352 errors.push(ValidationError::new(
353 ErrorCode::FutureDate,
354 format!("Entry dated in the future: {date}"),
355 date,
356 ));
357 }
358
359 match directive {
360 Directive::Open(open) => {
361 validate_open(&mut state, open, &mut errors);
362 }
363 Directive::Close(close) => {
364 validate_close(&mut state, close, &mut errors);
365 }
366 Directive::Transaction(txn) => {
367 validate_transaction(&mut state, txn, &mut errors);
368 }
369 Directive::Balance(bal) => {
370 validate_balance(&mut state, bal, &mut errors);
371 }
372 Directive::Commodity(comm) => {
373 state.commodities.insert(comm.currency.clone());
374 }
375 Directive::Pad(pad) => {
376 validate_pad(&mut state, pad, &mut errors);
377 }
378 Directive::Document(doc) => {
379 validate_document(&state, doc, &mut errors);
380 }
381 Directive::Note(note) => {
382 validate_note(&state, note, &mut errors);
383 }
384 _ => {}
385 }
386 }
387
388 for (target_account, pads) in &state.pending_pads {
390 for pad in pads {
391 if !pad.used {
392 errors.push(
393 ValidationError::new(
394 ErrorCode::PadWithoutBalance,
395 "Unused Pad entry".to_string(),
396 pad.date,
397 )
398 .with_context(format!(
399 " {} pad {} {}",
400 pad.date, target_account, pad.source_account
401 )),
402 );
403 }
404 }
405 }
406
407 errors
408}
409
410pub fn validate_spanned_with_options(
419 directives: &[Spanned<Directive>],
420 options: ValidationOptions,
421) -> Vec<ValidationError> {
422 let mut state = LedgerState::with_options(options);
423 let mut errors = Vec::new();
424
425 let today = jiff::Zoned::now().date();
426
427 let mut sorted: Vec<&Spanned<Directive>> = Vec::with_capacity(directives.len());
430 sorted.extend(directives.iter());
431 let sort_fn = |a: &&Spanned<Directive>, b: &&Spanned<Directive>| {
432 a.value
433 .date()
434 .cmp(&b.value.date())
435 .then_with(|| a.value.priority().cmp(&b.value.priority()))
436 .then_with(|| {
437 a.value
438 .has_cost_reduction()
439 .cmp(&b.value.has_cost_reduction())
440 })
441 };
442 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
443 sorted.par_sort_by(sort_fn);
444 } else {
445 sorted.sort_by(sort_fn);
446 }
447
448 for spanned in sorted {
449 let directive = &spanned.value;
450 let date = directive.date();
451
452 let error_count_before = errors.len();
457
458 if let Some(last) = state.last_date
460 && date < last
461 {
462 errors.push(ValidationError::with_location(
463 ErrorCode::DateOutOfOrder,
464 format!("Directive date {date} is before previous directive {last}"),
465 date,
466 spanned,
467 ));
468 }
469 state.last_date = Some(date);
470
471 if state.options.warn_future_dates && date > today {
473 errors.push(ValidationError::with_location(
474 ErrorCode::FutureDate,
475 format!("Entry dated in the future: {date}"),
476 date,
477 spanned,
478 ));
479 }
480
481 match directive {
482 Directive::Open(open) => {
483 validate_open(&mut state, open, &mut errors);
484 }
485 Directive::Close(close) => {
486 validate_close(&mut state, close, &mut errors);
487 }
488 Directive::Transaction(txn) => {
489 validate_transaction(&mut state, txn, &mut errors);
490 }
491 Directive::Balance(bal) => {
492 validate_balance(&mut state, bal, &mut errors);
493 }
494 Directive::Commodity(comm) => {
495 state.commodities.insert(comm.currency.clone());
496 }
497 Directive::Pad(pad) => {
498 validate_pad(&mut state, pad, &mut errors);
499 }
500 Directive::Document(doc) => {
501 validate_document(&state, doc, &mut errors);
502 }
503 Directive::Note(note) => {
504 validate_note(&state, note, &mut errors);
505 }
506 _ => {}
507 }
508
509 for error in errors.iter_mut().skip(error_count_before) {
514 if error.span.is_none() {
515 error.span = Some(spanned.span);
516 error.file_id = Some(spanned.file_id);
517 }
518 if error.note.is_none() && spanned.file_id == SYNTHESIZED_FILE_ID {
519 error.note = Some(
520 "directive was synthesized by a plugin (no source location); \
521 check your `plugin \"…\"` declarations for the responsible plugin"
522 .to_string(),
523 );
524 }
525 }
526 }
527
528 for (target_account, pads) in &state.pending_pads {
531 for pad in pads {
532 if !pad.used {
533 errors.push(
534 ValidationError::new(
535 ErrorCode::PadWithoutBalance,
536 "Unused Pad entry".to_string(),
537 pad.date,
538 )
539 .with_context(format!(
540 " {} pad {} {}",
541 pad.date, target_account, pad.source_account
542 )),
543 );
544 }
545 }
546 }
547
548 errors
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use rust_decimal_macros::dec;
555 use rustledger_core::{
556 Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
557 };
558
559 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
560 rustledger_core::naive_date(year, month, day).unwrap()
561 }
562
563 #[test]
564 fn test_validate_account_lifecycle() {
565 let directives = vec![
566 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
567 Directive::Transaction(
568 Transaction::new(date(2024, 1, 15), "Test")
569 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
570 .with_posting(Posting::new(
571 "Income:Salary",
572 Amount::new(dec!(-100), "USD"),
573 )),
574 ),
575 ];
576
577 let errors = validate(&directives);
578
579 assert!(errors
581 .iter()
582 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
583 }
584
585 #[test]
586 fn test_validate_account_used_before_open() {
587 let directives = vec![
588 Directive::Transaction(
589 Transaction::new(date(2024, 1, 1), "Test")
590 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
591 .with_posting(Posting::new(
592 "Income:Salary",
593 Amount::new(dec!(-100), "USD"),
594 )),
595 ),
596 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
597 ];
598
599 let errors = validate(&directives);
600
601 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
602 }
603
604 #[test]
605 fn test_validate_account_used_after_close() {
606 let directives = vec![
607 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
608 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
609 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
610 Directive::Transaction(
611 Transaction::new(date(2024, 7, 1), "Test")
612 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
613 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
614 ),
615 ];
616
617 let errors = validate(&directives);
618
619 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
620 }
621
622 #[test]
623 fn test_validate_balance_assertion() {
624 let directives = vec![
625 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
626 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
627 Directive::Transaction(
628 Transaction::new(date(2024, 1, 15), "Deposit")
629 .with_posting(Posting::new(
630 "Assets:Bank",
631 Amount::new(dec!(1000.00), "USD"),
632 ))
633 .with_posting(Posting::new(
634 "Income:Salary",
635 Amount::new(dec!(-1000.00), "USD"),
636 )),
637 ),
638 Directive::Balance(Balance::new(
639 date(2024, 1, 16),
640 "Assets:Bank",
641 Amount::new(dec!(1000.00), "USD"),
642 )),
643 ];
644
645 let errors = validate(&directives);
646 assert!(errors.is_empty(), "{errors:?}");
647 }
648
649 #[test]
650 fn test_validate_balance_assertion_failed() {
651 let directives = vec![
652 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
653 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
654 Directive::Transaction(
655 Transaction::new(date(2024, 1, 15), "Deposit")
656 .with_posting(Posting::new(
657 "Assets:Bank",
658 Amount::new(dec!(1000.00), "USD"),
659 ))
660 .with_posting(Posting::new(
661 "Income:Salary",
662 Amount::new(dec!(-1000.00), "USD"),
663 )),
664 ),
665 Directive::Balance(Balance::new(
666 date(2024, 1, 16),
667 "Assets:Bank",
668 Amount::new(dec!(500.00), "USD"), )),
670 ];
671
672 let errors = validate(&directives);
673 assert!(
674 errors
675 .iter()
676 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
677 );
678 }
679
680 #[test]
686 fn test_validate_balance_assertion_within_tolerance() {
687 let directives = vec![
692 Directive::Open(
693 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
694 ),
695 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
696 Directive::Transaction(
697 Transaction::new(date(2024, 1, 15), "Deposit")
698 .with_posting(Posting::new(
699 "Assets:Bank",
700 Amount::new(dec!(70.538), "ABC"), ))
702 .with_posting(Posting::new(
703 "Expenses:Misc",
704 Amount::new(dec!(-70.538), "ABC"),
705 )),
706 ),
707 Directive::Balance(Balance::new(
708 date(2024, 1, 16),
709 "Assets:Bank",
710 Amount::new(dec!(70.53), "ABC"), )),
712 ];
713
714 let errors = validate(&directives);
715 assert!(
716 errors.is_empty(),
717 "Balance within tolerance should pass: {errors:?}"
718 );
719 }
720
721 #[test]
723 fn test_validate_balance_assertion_exceeds_tolerance() {
724 let directives = vec![
729 Directive::Open(
730 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
731 ),
732 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
733 Directive::Transaction(
734 Transaction::new(date(2024, 1, 15), "Deposit")
735 .with_posting(Posting::new(
736 "Assets:Bank",
737 Amount::new(dec!(70.542), "ABC"),
738 ))
739 .with_posting(Posting::new(
740 "Expenses:Misc",
741 Amount::new(dec!(-70.542), "ABC"),
742 )),
743 ),
744 Directive::Balance(Balance::new(
745 date(2024, 1, 16),
746 "Assets:Bank",
747 Amount::new(dec!(70.53), "ABC"), )),
749 ];
750
751 let errors = validate(&directives);
752 assert!(
753 errors
754 .iter()
755 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
756 "Balance exceeding tolerance should fail"
757 );
758 }
759
760 #[test]
761 fn test_validate_unbalanced_transaction() {
762 let directives = vec![
763 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
764 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
765 Directive::Transaction(
766 Transaction::new(date(2024, 1, 15), "Unbalanced")
767 .with_posting(Posting::new(
768 "Assets:Bank",
769 Amount::new(dec!(-50.00), "USD"),
770 ))
771 .with_posting(Posting::new(
772 "Expenses:Food",
773 Amount::new(dec!(40.00), "USD"),
774 )), ),
776 ];
777
778 let errors = validate(&directives);
779 assert!(
780 errors
781 .iter()
782 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
783 );
784 }
785
786 #[test]
787 fn test_validate_currency_not_allowed() {
788 let directives = vec![
789 Directive::Open(
790 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
791 ),
792 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
793 Directive::Transaction(
794 Transaction::new(date(2024, 1, 15), "Test")
795 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
797 "Income:Salary",
798 Amount::new(dec!(-100.00), "EUR"),
799 )),
800 ),
801 ];
802
803 let errors = validate(&directives);
804 assert!(
805 errors
806 .iter()
807 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
808 );
809 }
810
811 #[test]
812 fn test_validate_future_date_warning() {
813 let future_date = jiff::Zoned::now()
815 .date()
816 .checked_add(jiff::ToSpan::days(30))
817 .unwrap();
818
819 let directives = vec![Directive::Open(Open {
820 date: future_date,
821 account: "Assets:Bank".into(),
822 currencies: vec![],
823 booking: None,
824 meta: Default::default(),
825 })];
826
827 let errors = validate(&directives);
829 assert!(
830 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
831 "Should not warn about future dates by default"
832 );
833
834 let options = ValidationOptions::default().with_warn_future_dates(true);
836 let errors = validate_with_options(&directives, options);
837 assert!(
838 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
839 "Should warn about future dates when enabled"
840 );
841 }
842
843 #[test]
844 fn test_validate_document_not_found() {
845 let directives = vec![
846 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
847 Directive::Document(Document {
848 date: date(2024, 1, 15),
849 account: "Assets:Bank".into(),
850 path: "/nonexistent/path/to/document.pdf".to_string(),
851 tags: vec![],
852 links: vec![],
853 meta: Default::default(),
854 }),
855 ];
856
857 let errors = validate(&directives);
859 assert!(
860 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
861 "Should check documents by default"
862 );
863
864 let options = ValidationOptions::default().with_check_documents(false);
866 let errors = validate_with_options(&directives, options);
867 assert!(
868 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
869 "Should not report missing document when disabled"
870 );
871 }
872
873 #[test]
874 fn test_validate_document_account_not_open() {
875 let directives = vec![Directive::Document(Document {
876 date: date(2024, 1, 15),
877 account: "Assets:Unknown".into(),
878 path: "receipt.pdf".to_string(),
879 tags: vec![],
880 links: vec![],
881 meta: Default::default(),
882 })];
883
884 let errors = validate(&directives);
885 assert!(
886 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
887 "Should error for document on unopened account"
888 );
889 }
890
891 #[test]
892 fn test_validate_document_relative_path_in_document_dirs() {
893 let filename = "rustledger_test_889_relative_receipt.pdf";
897 let dir = tempfile::tempdir().unwrap();
898 let doc_subdir = dir.path().join("documents");
899 std::fs::create_dir_all(&doc_subdir).unwrap();
900 std::fs::write(doc_subdir.join(filename), "test").unwrap();
901
902 let directives = vec![
903 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
904 Directive::Document(Document {
905 date: date(2024, 1, 15),
906 account: "Assets:Bank".into(),
907 path: filename.to_string(),
908 tags: vec![],
909 links: vec![],
910 meta: Default::default(),
911 }),
912 ];
913
914 let errors = validate(&directives);
916 assert!(
917 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
918 "Should error when document_dirs not set"
919 );
920
921 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
923 let errors = validate_with_options(&directives, options);
924 assert!(
925 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
926 "Should find document in document_dirs: {errors:?}"
927 );
928 }
929
930 #[test]
931 fn test_validate_document_relative_path_not_found_in_dirs() {
932 let filename = "rustledger_test_889_nonexistent.pdf";
934 let dir = tempfile::tempdir().unwrap();
935 let doc_subdir = dir.path().join("documents");
936 std::fs::create_dir_all(&doc_subdir).unwrap();
937
938 let directives = vec![
939 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
940 Directive::Document(Document {
941 date: date(2024, 1, 15),
942 account: "Assets:Bank".into(),
943 path: filename.to_string(),
944 tags: vec![],
945 links: vec![],
946 meta: Default::default(),
947 }),
948 ];
949
950 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
951 let errors = validate_with_options(&directives, options);
952 assert!(
953 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
954 "Should error when file not found in any document_dir"
955 );
956 }
957
958 #[test]
959 fn test_validate_document_absolute_path_ignores_document_dirs() {
960 let filename = "rustledger_test_889_absolute_receipt.pdf";
961 let dir = tempfile::tempdir().unwrap();
962 let doc_subdir = dir.path().join("documents");
963 std::fs::create_dir_all(&doc_subdir).unwrap();
964 std::fs::write(doc_subdir.join(filename), "test").unwrap();
965
966 let directives = vec![
967 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
968 Directive::Document(Document {
969 date: date(2024, 1, 15),
970 account: "Assets:Bank".into(),
971 path: doc_subdir.join(filename).display().to_string(),
972 tags: vec![],
973 links: vec![],
974 meta: Default::default(),
975 }),
976 ];
977
978 let options = ValidationOptions::default()
980 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
981 let errors = validate_with_options(&directives, options);
982 assert!(
983 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
984 "Absolute path should work even with wrong document_dirs: {errors:?}"
985 );
986 }
987
988 #[test]
989 fn test_error_code_is_warning() {
990 assert!(!ErrorCode::AccountNotOpen.is_warning());
991 assert!(!ErrorCode::DocumentNotFound.is_warning());
992 assert!(ErrorCode::FutureDate.is_warning());
993 }
994
995 #[test]
996 fn test_validate_pad_basic() {
997 let directives = vec![
998 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
999 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1000 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1001 Directive::Balance(Balance::new(
1002 date(2024, 1, 2),
1003 "Assets:Bank",
1004 Amount::new(dec!(1000.00), "USD"),
1005 )),
1006 ];
1007
1008 let errors = validate(&directives);
1009 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1011 }
1012
1013 #[test]
1014 fn test_validate_pad_with_existing_balance() {
1015 let directives = vec![
1016 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1017 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1018 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1019 Directive::Transaction(
1021 Transaction::new(date(2024, 1, 5), "Initial deposit")
1022 .with_posting(Posting::new(
1023 "Assets:Bank",
1024 Amount::new(dec!(500.00), "USD"),
1025 ))
1026 .with_posting(Posting::new(
1027 "Income:Salary",
1028 Amount::new(dec!(-500.00), "USD"),
1029 )),
1030 ),
1031 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1033 Directive::Balance(Balance::new(
1034 date(2024, 1, 15),
1035 "Assets:Bank",
1036 Amount::new(dec!(1000.00), "USD"), )),
1038 ];
1039
1040 let errors = validate(&directives);
1041 assert!(
1043 errors.is_empty(),
1044 "Pad should add missing amount: {errors:?}"
1045 );
1046 }
1047
1048 #[test]
1049 fn test_validate_pad_account_not_open() {
1050 let directives = vec![
1051 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1052 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1054 ];
1055
1056 let errors = validate(&directives);
1057 assert!(
1058 errors
1059 .iter()
1060 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1061 "Should error for pad on unopened account"
1062 );
1063 }
1064
1065 #[test]
1066 fn test_validate_pad_source_not_open() {
1067 let directives = vec![
1068 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1069 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1071 ];
1072
1073 let errors = validate(&directives);
1074 assert!(
1075 errors.iter().any(
1076 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1077 ),
1078 "Should error for pad with unopened source account"
1079 );
1080 }
1081
1082 #[test]
1083 fn test_validate_pad_negative_adjustment() {
1084 let directives = vec![
1086 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1087 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1088 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1089 Directive::Transaction(
1091 Transaction::new(date(2024, 1, 5), "Big deposit")
1092 .with_posting(Posting::new(
1093 "Assets:Bank",
1094 Amount::new(dec!(2000.00), "USD"),
1095 ))
1096 .with_posting(Posting::new(
1097 "Income:Salary",
1098 Amount::new(dec!(-2000.00), "USD"),
1099 )),
1100 ),
1101 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1103 Directive::Balance(Balance::new(
1104 date(2024, 1, 15),
1105 "Assets:Bank",
1106 Amount::new(dec!(1000.00), "USD"), )),
1108 ];
1109
1110 let errors = validate(&directives);
1111 assert!(
1112 errors.is_empty(),
1113 "Pad should handle negative adjustment: {errors:?}"
1114 );
1115 }
1116
1117 #[test]
1118 fn test_validate_insufficient_units() {
1119 use rustledger_core::CostSpec;
1120
1121 let cost_spec = CostSpec::empty()
1122 .with_number_per(dec!(150))
1123 .with_currency("USD");
1124
1125 let directives = vec![
1126 Directive::Open(
1127 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1128 ),
1129 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1130 Directive::Transaction(
1132 Transaction::new(date(2024, 1, 15), "Buy")
1133 .with_posting(
1134 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1135 .with_cost(cost_spec.clone()),
1136 )
1137 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1138 ),
1139 Directive::Transaction(
1141 Transaction::new(date(2024, 6, 1), "Sell too many")
1142 .with_posting(
1143 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1144 .with_cost(cost_spec),
1145 )
1146 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1147 ),
1148 ];
1149
1150 let errors = validate(&directives);
1151 assert!(
1152 errors
1153 .iter()
1154 .any(|e| e.code == ErrorCode::InsufficientUnits),
1155 "Should error for insufficient units: {errors:?}"
1156 );
1157 }
1158
1159 #[test]
1160 fn test_validate_no_matching_lot() {
1161 use rustledger_core::CostSpec;
1162
1163 let directives = vec![
1164 Directive::Open(
1165 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1166 ),
1167 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1168 Directive::Transaction(
1170 Transaction::new(date(2024, 1, 15), "Buy")
1171 .with_posting(
1172 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1173 CostSpec::empty()
1174 .with_number_per(dec!(150))
1175 .with_currency("USD"),
1176 ),
1177 )
1178 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1179 ),
1180 Directive::Transaction(
1182 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1183 .with_posting(
1184 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1185 CostSpec::empty()
1186 .with_number_per(dec!(160))
1187 .with_currency("USD"),
1188 ),
1189 )
1190 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1191 ),
1192 ];
1193
1194 let errors = validate(&directives);
1195 assert!(
1196 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1197 "Should error for no matching lot: {errors:?}"
1198 );
1199 }
1200
1201 #[test]
1202 fn test_validate_multiple_lot_match_uses_fifo() {
1203 use rustledger_core::CostSpec;
1206
1207 let cost_spec = CostSpec::empty()
1208 .with_number_per(dec!(150))
1209 .with_currency("USD");
1210
1211 let directives = vec![
1212 Directive::Open(
1213 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1214 ),
1215 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1216 Directive::Transaction(
1218 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1219 .with_posting(
1220 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1221 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1222 )
1223 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1224 ),
1225 Directive::Transaction(
1227 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1228 .with_posting(
1229 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1230 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1231 )
1232 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1233 ),
1234 Directive::Transaction(
1236 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1237 .with_posting(
1238 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1239 .with_cost(cost_spec),
1240 )
1241 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1242 ),
1243 ];
1244
1245 let errors = validate(&directives);
1246 let booking_errors: Vec<_> = errors
1248 .iter()
1249 .filter(|e| {
1250 matches!(
1251 e.code,
1252 ErrorCode::InsufficientUnits
1253 | ErrorCode::NoMatchingLot
1254 | ErrorCode::AmbiguousLotMatch
1255 )
1256 })
1257 .collect();
1258 assert!(
1259 booking_errors.is_empty(),
1260 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1261 );
1262 }
1263
1264 #[test]
1265 fn test_validate_successful_booking() {
1266 use rustledger_core::CostSpec;
1267
1268 let cost_spec = CostSpec::empty()
1269 .with_number_per(dec!(150))
1270 .with_currency("USD");
1271
1272 let directives = vec![
1273 Directive::Open(
1274 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1275 ),
1276 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1277 Directive::Transaction(
1279 Transaction::new(date(2024, 1, 15), "Buy")
1280 .with_posting(
1281 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1282 .with_cost(cost_spec.clone()),
1283 )
1284 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1285 ),
1286 Directive::Transaction(
1288 Transaction::new(date(2024, 6, 1), "Sell")
1289 .with_posting(
1290 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1291 .with_cost(cost_spec),
1292 )
1293 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1294 ),
1295 ];
1296
1297 let errors = validate(&directives);
1298 let booking_errors: Vec<_> = errors
1300 .iter()
1301 .filter(|e| {
1302 matches!(
1303 e.code,
1304 ErrorCode::InsufficientUnits
1305 | ErrorCode::NoMatchingLot
1306 | ErrorCode::AmbiguousLotMatch
1307 )
1308 })
1309 .collect();
1310 assert!(
1311 booking_errors.is_empty(),
1312 "Should have no booking errors: {booking_errors:?}"
1313 );
1314 }
1315
1316 #[test]
1317 fn test_validate_account_already_open() {
1318 let directives = vec![
1319 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1320 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1322
1323 let errors = validate(&directives);
1324 assert!(
1325 errors
1326 .iter()
1327 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1328 "Should error for duplicate open: {errors:?}"
1329 );
1330 }
1331
1332 #[test]
1333 fn test_validate_account_close_not_empty() {
1334 let directives = vec![
1335 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1336 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1337 Directive::Transaction(
1338 Transaction::new(date(2024, 1, 15), "Deposit")
1339 .with_posting(Posting::new(
1340 "Assets:Bank",
1341 Amount::new(dec!(100.00), "USD"),
1342 ))
1343 .with_posting(Posting::new(
1344 "Income:Salary",
1345 Amount::new(dec!(-100.00), "USD"),
1346 )),
1347 ),
1348 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1350
1351 let errors = validate(&directives);
1352 assert!(
1353 errors
1354 .iter()
1355 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1356 "Should warn for closing account with balance: {errors:?}"
1357 );
1358 }
1359
1360 #[test]
1361 fn test_validate_no_postings_allowed() {
1362 let directives = vec![
1365 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1366 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1367 ];
1368
1369 let errors = validate(&directives);
1370 assert!(
1371 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1372 "Should NOT error for transaction with no postings: {errors:?}"
1373 );
1374 }
1375
1376 #[test]
1377 fn test_validate_single_posting() {
1378 let directives = vec![
1379 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1380 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1381 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1382 )),
1383 ];
1384
1385 let errors = validate(&directives);
1386 assert!(
1387 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1388 "Should warn for transaction with single posting: {errors:?}"
1389 );
1390 assert!(ErrorCode::SinglePosting.is_warning());
1392 }
1393
1394 #[test]
1395 fn test_validate_single_posting_zero_cost_no_warning() {
1396 let directives = vec![
1400 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1401 Directive::Transaction(
1402 Transaction::new(date(2024, 1, 15), "Grant").with_posting(
1403 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1404 rustledger_core::CostSpec::empty()
1405 .with_number_per(dec!(0))
1406 .with_currency("USD"),
1407 ),
1408 ),
1409 ),
1410 ];
1411
1412 let errors = validate(&directives);
1413 assert!(
1414 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1415 "Should NOT warn for zero-cost single posting: {errors:?}"
1416 );
1417 }
1418
1419 #[test]
1420 fn test_validate_single_posting_nonzero_cost_still_warns() {
1421 let directives = vec![
1423 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1424 Directive::Transaction(
1425 Transaction::new(date(2024, 1, 15), "Buy").with_posting(
1426 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1427 rustledger_core::CostSpec::empty()
1428 .with_number_per(dec!(150))
1429 .with_currency("USD"),
1430 ),
1431 ),
1432 ),
1433 ];
1434
1435 let errors = validate(&directives);
1436 assert!(
1437 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1438 "Should warn for single posting with non-zero cost: {errors:?}"
1439 );
1440 }
1441
1442 #[test]
1443 fn test_validate_pad_without_balance() {
1444 let directives = vec![
1445 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1446 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1447 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1448 ];
1450
1451 let errors = validate(&directives);
1452 assert!(
1453 errors
1454 .iter()
1455 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1456 "Should error for pad without subsequent balance: {errors:?}"
1457 );
1458 }
1459
1460 #[test]
1461 fn test_validate_multiple_pads_for_balance() {
1462 let directives = vec![
1463 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1464 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1465 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1466 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1468 date(2024, 1, 3),
1469 "Assets:Bank",
1470 Amount::new(dec!(1000.00), "USD"),
1471 )),
1472 ];
1473
1474 let errors = validate(&directives);
1475 assert!(
1476 errors
1477 .iter()
1478 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1479 "Should error for multiple pads before balance: {errors:?}"
1480 );
1481 }
1482
1483 #[test]
1484 fn test_error_severity() {
1485 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1487 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1488 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1489
1490 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1492 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1493 assert_eq!(
1494 ErrorCode::AccountCloseNotEmpty.severity(),
1495 Severity::Warning
1496 );
1497
1498 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1500 }
1501
1502 #[test]
1503 fn test_validate_invalid_account_name() {
1504 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1506
1507 let errors = validate(&directives);
1508 assert!(
1509 errors
1510 .iter()
1511 .any(|e| e.code == ErrorCode::InvalidAccountName),
1512 "Should error for invalid account root: {errors:?}"
1513 );
1514 }
1515
1516 #[test]
1517 fn test_validate_account_lowercase_component() {
1518 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1520
1521 let errors = validate(&directives);
1522 assert!(
1523 errors
1524 .iter()
1525 .any(|e| e.code == ErrorCode::InvalidAccountName),
1526 "Should error for lowercase component: {errors:?}"
1527 );
1528 }
1529
1530 #[test]
1531 fn test_validate_valid_account_names() {
1532 let valid_names = [
1534 "Assets:Bank",
1535 "Assets:Bank:Checking",
1536 "Liabilities:CreditCard",
1537 "Equity:Opening-Balances",
1538 "Income:Salary2024",
1539 "Expenses:Food:Restaurant",
1540 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
1546
1547 for name in valid_names {
1548 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1549
1550 let errors = validate(&directives);
1551 let name_errors: Vec<_> = errors
1552 .iter()
1553 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1554 .collect();
1555 assert!(
1556 name_errors.is_empty(),
1557 "Should accept valid account name '{name}': {name_errors:?}"
1558 );
1559 }
1560 }
1561
1562 #[test]
1567 fn test_e2002_balance_exceeds_explicit_tolerance() {
1568 let directives = vec![
1571 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1572 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1573 Directive::Transaction(
1574 Transaction::new(date(2024, 1, 15), "Deposit")
1575 .with_posting(Posting::new(
1576 "Assets:Bank",
1577 Amount::new(dec!(1000.00), "USD"),
1578 ))
1579 .with_posting(Posting::new(
1580 "Income:Salary",
1581 Amount::new(dec!(-1000.00), "USD"),
1582 )),
1583 ),
1584 Directive::Balance(
1587 Balance::new(
1588 date(2024, 1, 16),
1589 "Assets:Bank",
1590 Amount::new(dec!(999.00), "USD"),
1591 )
1592 .with_tolerance(dec!(0.01)),
1593 ),
1594 ];
1595
1596 let errors = validate(&directives);
1597
1598 assert!(
1599 errors
1600 .iter()
1601 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
1602 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
1603 );
1604 }
1605
1606 #[test]
1607 fn test_e2002_balance_within_explicit_tolerance_passes() {
1608 let directives = vec![
1610 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1611 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1612 Directive::Transaction(
1613 Transaction::new(date(2024, 1, 15), "Deposit")
1614 .with_posting(Posting::new(
1615 "Assets:Bank",
1616 Amount::new(dec!(1000.00), "USD"),
1617 ))
1618 .with_posting(Posting::new(
1619 "Income:Salary",
1620 Amount::new(dec!(-1000.00), "USD"),
1621 )),
1622 ),
1623 Directive::Balance(
1625 Balance::new(
1626 date(2024, 1, 16),
1627 "Assets:Bank",
1628 Amount::new(dec!(999.00), "USD"),
1629 )
1630 .with_tolerance(dec!(5.00)),
1631 ),
1632 ];
1633
1634 let errors = validate(&directives);
1635
1636 assert!(
1637 !errors
1638 .iter()
1639 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
1640 || e.code == ErrorCode::BalanceAssertionFailed),
1641 "Expected no balance errors, got: {errors:?}"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_e5001_undeclared_currency() {
1647 use rustledger_core::Commodity;
1650
1651 let directives = vec![
1652 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1653 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1654 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1655 Directive::Transaction(
1656 Transaction::new(date(2024, 1, 15), "Lunch")
1657 .with_posting(Posting::new(
1658 "Expenses:Food",
1659 Amount::new(dec!(20.00), "EUR"), ))
1661 .with_posting(Posting::new(
1662 "Assets:Bank",
1663 Amount::new(dec!(-20.00), "EUR"),
1664 )),
1665 ),
1666 ];
1667
1668 let options = ValidationOptions::default().with_require_commodities(true);
1669 let errors = validate_with_options(&directives, options);
1670
1671 assert!(
1672 errors
1673 .iter()
1674 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1675 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
1676 );
1677 }
1678
1679 #[test]
1680 fn test_e5001_declared_currency_passes() {
1681 use rustledger_core::Commodity;
1683
1684 let directives = vec![
1685 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1686 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
1687 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1688 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1689 Directive::Transaction(
1690 Transaction::new(date(2024, 1, 15), "Lunch")
1691 .with_posting(Posting::new(
1692 "Expenses:Food",
1693 Amount::new(dec!(20.00), "EUR"),
1694 ))
1695 .with_posting(Posting::new(
1696 "Assets:Bank",
1697 Amount::new(dec!(-20.00), "EUR"),
1698 )),
1699 ),
1700 ];
1701
1702 let options = ValidationOptions::default().with_require_commodities(true);
1703 let errors = validate_with_options(&directives, options);
1704
1705 assert!(
1706 !errors
1707 .iter()
1708 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1709 "Expected no E5001 errors, got: {errors:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_e5001_not_raised_without_require_commodities() {
1715 let directives = vec![
1717 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1718 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1719 Directive::Transaction(
1720 Transaction::new(date(2024, 1, 15), "Lunch")
1721 .with_posting(Posting::new(
1722 "Expenses:Food",
1723 Amount::new(dec!(20.00), "XYZ"), ))
1725 .with_posting(Posting::new(
1726 "Assets:Bank",
1727 Amount::new(dec!(-20.00), "XYZ"),
1728 )),
1729 ),
1730 ];
1731
1732 let errors = validate(&directives);
1733
1734 assert!(
1735 !errors
1736 .iter()
1737 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1738 "Should not raise E5001 without require_commodities, got: {errors:?}"
1739 );
1740 }
1741
1742 #[test]
1743 fn test_e3002_multiple_missing_amounts() {
1744 let directives = vec![
1746 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1747 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1748 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
1749 Directive::Transaction(
1750 Transaction::new(date(2024, 1, 15), "Lunch")
1751 .with_posting(Posting::new(
1752 "Assets:Bank",
1753 Amount::new(dec!(-50.00), "USD"),
1754 ))
1755 .with_posting(Posting {
1757 account: "Expenses:Food".into(),
1758 units: None,
1759 cost: None,
1760 price: None,
1761 flag: None,
1762 meta: Default::default(),
1763 comments: vec![],
1764 trailing_comments: vec![],
1765 })
1766 .with_posting(Posting {
1767 account: "Expenses:Drinks".into(),
1768 units: None,
1769 cost: None,
1770 price: None,
1771 flag: None,
1772 meta: Default::default(),
1773 comments: vec![],
1774 trailing_comments: vec![],
1775 }),
1776 ),
1777 ];
1778
1779 let errors = validate(&directives);
1780
1781 assert!(
1782 errors
1783 .iter()
1784 .any(|e| e.code == ErrorCode::MultipleInterpolation),
1785 "Expected E3002 MultipleInterpolation, got: {errors:?}"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_e3002_single_missing_amount_ok() {
1791 let directives = vec![
1793 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1794 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1795 Directive::Transaction(
1796 Transaction::new(date(2024, 1, 15), "Lunch")
1797 .with_posting(Posting::new(
1798 "Assets:Bank",
1799 Amount::new(dec!(-50.00), "USD"),
1800 ))
1801 .with_posting(Posting {
1802 account: "Expenses:Food".into(),
1803 units: None,
1804 cost: None,
1805 price: None,
1806 flag: None,
1807 meta: Default::default(),
1808 comments: vec![],
1809 trailing_comments: vec![],
1810 }),
1811 ),
1812 ];
1813
1814 let errors = validate(&directives);
1815
1816 assert!(
1817 !errors
1818 .iter()
1819 .any(|e| e.code == ErrorCode::MultipleInterpolation),
1820 "Should not raise E3002 with single missing amount, got: {errors:?}"
1821 );
1822 }
1823
1824 #[test]
1825 fn test_e7001_unknown_option() {
1826 let state = LedgerState::new();
1828 let mut errors = Vec::new();
1829
1830 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
1831
1832 assert_eq!(errors.len(), 1);
1833 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
1834 assert!(errors[0].message.contains("bogus_option"));
1835 }
1836
1837 #[test]
1838 fn test_e7002_invalid_option_value() {
1839 let state = LedgerState::new();
1840 let mut errors = Vec::new();
1841
1842 state.import_option_warnings(
1843 &[("E7002", "Invalid leaf account name: 'not-valid'")],
1844 &mut errors,
1845 );
1846
1847 assert_eq!(errors.len(), 1);
1848 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
1849 }
1850
1851 #[test]
1852 fn test_e7003_duplicate_option() {
1853 let state = LedgerState::new();
1854 let mut errors = Vec::new();
1855
1856 state.import_option_warnings(
1857 &[("E7003", "Option \"title\" can only be specified once")],
1858 &mut errors,
1859 );
1860
1861 assert_eq!(errors.len(), 1);
1862 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
1863 }
1864}