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::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#[derive(Debug, Clone)]
83pub struct ValidationOptions {
84 pub require_commodities: bool,
86 pub check_documents: bool,
88 pub warn_future_dates: bool,
90 pub document_base: Option<std::path::PathBuf>,
92 pub account_types: Vec<String>,
95 pub infer_tolerance_from_cost: bool,
98 pub tolerance_multiplier: Decimal,
101 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
104}
105
106impl Default for ValidationOptions {
107 fn default() -> Self {
108 Self {
109 require_commodities: false,
110 check_documents: true, warn_future_dates: false,
112 document_base: None,
113 account_types: vec![
114 "Assets".to_string(),
115 "Liabilities".to_string(),
116 "Equity".to_string(),
117 "Income".to_string(),
118 "Expenses".to_string(),
119 ],
120 infer_tolerance_from_cost: false,
122 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
130struct PendingPad {
131 source_account: InternedStr,
133 date: NaiveDate,
135 used: bool,
137}
138
139#[derive(Debug, Default)]
141pub struct LedgerState {
142 accounts: FxHashMap<InternedStr, AccountState>,
144 inventories: FxHashMap<InternedStr, Inventory>,
146 commodities: FxHashSet<InternedStr>,
148 pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
150 options: ValidationOptions,
152 last_date: Option<NaiveDate>,
154 tolerances: FxHashMap<InternedStr, Decimal>,
157}
158
159impl LedgerState {
160 #[must_use]
162 pub fn new() -> Self {
163 Self::default()
164 }
165
166 #[must_use]
168 pub fn with_options(options: ValidationOptions) -> Self {
169 Self {
170 options,
171 ..Default::default()
172 }
173 }
174
175 pub const fn set_require_commodities(&mut self, require: bool) {
177 self.options.require_commodities = require;
178 }
179
180 pub const fn set_check_documents(&mut self, check: bool) {
182 self.options.check_documents = check;
183 }
184
185 pub const fn set_warn_future_dates(&mut self, warn: bool) {
187 self.options.warn_future_dates = warn;
188 }
189
190 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
192 self.options.document_base = Some(base.into());
193 }
194
195 #[must_use]
197 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
198 self.inventories.get(account)
199 }
200
201 pub fn accounts(&self) -> impl Iterator<Item = &str> {
203 self.accounts.keys().map(InternedStr::as_str)
204 }
205
206 pub fn import_option_warnings(
214 &self,
215 warnings: &[(&str, &str)],
216 errors: &mut Vec<ValidationError>,
217 ) {
218 for &(code, message) in warnings {
219 let error_code = match code {
220 "E7001" => ErrorCode::UnknownOption,
221 "E7002" => ErrorCode::InvalidOptionValue,
222 "E7003" => ErrorCode::DuplicateOption,
223 _ => continue,
224 };
225 errors.push(ValidationError::new(
226 error_code,
227 message.to_string(),
228 NaiveDate::default(),
230 ));
231 }
232 }
233}
234
235pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
239 validate_with_options(directives, ValidationOptions::default())
240}
241
242pub fn validate_with_options(
246 directives: &[Directive],
247 options: ValidationOptions,
248) -> Vec<ValidationError> {
249 let mut state = LedgerState::with_options(options);
250 let mut errors = Vec::new();
251
252 let today = jiff::Zoned::now().date();
253
254 let mut sorted: Vec<&Directive> = Vec::with_capacity(directives.len());
258 sorted.extend(directives.iter());
259 let sort_fn = |a: &&Directive, b: &&Directive| {
260 a.date()
261 .cmp(&b.date())
262 .then_with(|| a.priority().cmp(&b.priority()))
263 .then_with(|| a.has_cost_reduction().cmp(&b.has_cost_reduction()))
264 };
265 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
266 sorted.par_sort_by(sort_fn);
267 } else {
268 sorted.sort_by(sort_fn);
269 }
270
271 for directive in sorted {
272 let date = directive.date();
273
274 if let Some(last) = state.last_date
276 && date < last
277 {
278 errors.push(ValidationError::new(
279 ErrorCode::DateOutOfOrder,
280 format!("Directive date {date} is before previous directive {last}"),
281 date,
282 ));
283 }
284 state.last_date = Some(date);
285
286 if state.options.warn_future_dates && date > today {
288 errors.push(ValidationError::new(
289 ErrorCode::FutureDate,
290 format!("Entry dated in the future: {date}"),
291 date,
292 ));
293 }
294
295 match directive {
296 Directive::Open(open) => {
297 validate_open(&mut state, open, &mut errors);
298 }
299 Directive::Close(close) => {
300 validate_close(&mut state, close, &mut errors);
301 }
302 Directive::Transaction(txn) => {
303 validate_transaction(&mut state, txn, &mut errors);
304 }
305 Directive::Balance(bal) => {
306 validate_balance(&mut state, bal, &mut errors);
307 }
308 Directive::Commodity(comm) => {
309 state.commodities.insert(comm.currency.clone());
310 }
311 Directive::Pad(pad) => {
312 validate_pad(&mut state, pad, &mut errors);
313 }
314 Directive::Document(doc) => {
315 validate_document(&state, doc, &mut errors);
316 }
317 Directive::Note(note) => {
318 validate_note(&state, note, &mut errors);
319 }
320 _ => {}
321 }
322 }
323
324 for (target_account, pads) in &state.pending_pads {
326 for pad in pads {
327 if !pad.used {
328 errors.push(
329 ValidationError::new(
330 ErrorCode::PadWithoutBalance,
331 "Unused Pad entry".to_string(),
332 pad.date,
333 )
334 .with_context(format!(
335 " {} pad {} {}",
336 pad.date, target_account, pad.source_account
337 )),
338 );
339 }
340 }
341 }
342
343 errors
344}
345
346pub fn validate_spanned_with_options(
355 directives: &[Spanned<Directive>],
356 options: ValidationOptions,
357) -> Vec<ValidationError> {
358 let mut state = LedgerState::with_options(options);
359 let mut errors = Vec::new();
360
361 let today = jiff::Zoned::now().date();
362
363 let mut sorted: Vec<&Spanned<Directive>> = Vec::with_capacity(directives.len());
366 sorted.extend(directives.iter());
367 let sort_fn = |a: &&Spanned<Directive>, b: &&Spanned<Directive>| {
368 a.value
369 .date()
370 .cmp(&b.value.date())
371 .then_with(|| a.value.priority().cmp(&b.value.priority()))
372 .then_with(|| {
373 a.value
374 .has_cost_reduction()
375 .cmp(&b.value.has_cost_reduction())
376 })
377 };
378 if sorted.len() >= PARALLEL_SORT_THRESHOLD {
379 sorted.par_sort_by(sort_fn);
380 } else {
381 sorted.sort_by(sort_fn);
382 }
383
384 for spanned in sorted {
385 let directive = &spanned.value;
386 let date = directive.date();
387
388 if let Some(last) = state.last_date
390 && date < last
391 {
392 errors.push(ValidationError::with_location(
393 ErrorCode::DateOutOfOrder,
394 format!("Directive date {date} is before previous directive {last}"),
395 date,
396 spanned,
397 ));
398 }
399 state.last_date = Some(date);
400
401 if state.options.warn_future_dates && date > today {
403 errors.push(ValidationError::with_location(
404 ErrorCode::FutureDate,
405 format!("Entry dated in the future: {date}"),
406 date,
407 spanned,
408 ));
409 }
410
411 let error_count_before = errors.len();
413
414 match directive {
415 Directive::Open(open) => {
416 validate_open(&mut state, open, &mut errors);
417 }
418 Directive::Close(close) => {
419 validate_close(&mut state, close, &mut errors);
420 }
421 Directive::Transaction(txn) => {
422 validate_transaction(&mut state, txn, &mut errors);
423 }
424 Directive::Balance(bal) => {
425 validate_balance(&mut state, bal, &mut errors);
426 }
427 Directive::Commodity(comm) => {
428 state.commodities.insert(comm.currency.clone());
429 }
430 Directive::Pad(pad) => {
431 validate_pad(&mut state, pad, &mut errors);
432 }
433 Directive::Document(doc) => {
434 validate_document(&state, doc, &mut errors);
435 }
436 Directive::Note(note) => {
437 validate_note(&state, note, &mut errors);
438 }
439 _ => {}
440 }
441
442 for error in errors.iter_mut().skip(error_count_before) {
444 if error.span.is_none() {
445 error.span = Some(spanned.span);
446 error.file_id = Some(spanned.file_id);
447 }
448 }
449 }
450
451 for (target_account, pads) in &state.pending_pads {
454 for pad in pads {
455 if !pad.used {
456 errors.push(
457 ValidationError::new(
458 ErrorCode::PadWithoutBalance,
459 "Unused Pad entry".to_string(),
460 pad.date,
461 )
462 .with_context(format!(
463 " {} pad {} {}",
464 pad.date, target_account, pad.source_account
465 )),
466 );
467 }
468 }
469 }
470
471 errors
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use rust_decimal_macros::dec;
478 use rustledger_core::{
479 Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
480 };
481
482 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
483 rustledger_core::naive_date(year, month, day).unwrap()
484 }
485
486 #[test]
487 fn test_validate_account_lifecycle() {
488 let directives = vec![
489 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
490 Directive::Transaction(
491 Transaction::new(date(2024, 1, 15), "Test")
492 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
493 .with_posting(Posting::new(
494 "Income:Salary",
495 Amount::new(dec!(-100), "USD"),
496 )),
497 ),
498 ];
499
500 let errors = validate(&directives);
501
502 assert!(errors
504 .iter()
505 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
506 }
507
508 #[test]
509 fn test_validate_account_used_before_open() {
510 let directives = vec![
511 Directive::Transaction(
512 Transaction::new(date(2024, 1, 1), "Test")
513 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
514 .with_posting(Posting::new(
515 "Income:Salary",
516 Amount::new(dec!(-100), "USD"),
517 )),
518 ),
519 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
520 ];
521
522 let errors = validate(&directives);
523
524 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
525 }
526
527 #[test]
528 fn test_validate_account_used_after_close() {
529 let directives = vec![
530 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
531 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
532 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
533 Directive::Transaction(
534 Transaction::new(date(2024, 7, 1), "Test")
535 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
536 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
537 ),
538 ];
539
540 let errors = validate(&directives);
541
542 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
543 }
544
545 #[test]
546 fn test_validate_balance_assertion() {
547 let directives = vec![
548 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
549 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
550 Directive::Transaction(
551 Transaction::new(date(2024, 1, 15), "Deposit")
552 .with_posting(Posting::new(
553 "Assets:Bank",
554 Amount::new(dec!(1000.00), "USD"),
555 ))
556 .with_posting(Posting::new(
557 "Income:Salary",
558 Amount::new(dec!(-1000.00), "USD"),
559 )),
560 ),
561 Directive::Balance(Balance::new(
562 date(2024, 1, 16),
563 "Assets:Bank",
564 Amount::new(dec!(1000.00), "USD"),
565 )),
566 ];
567
568 let errors = validate(&directives);
569 assert!(errors.is_empty(), "{errors:?}");
570 }
571
572 #[test]
573 fn test_validate_balance_assertion_failed() {
574 let directives = vec![
575 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
576 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
577 Directive::Transaction(
578 Transaction::new(date(2024, 1, 15), "Deposit")
579 .with_posting(Posting::new(
580 "Assets:Bank",
581 Amount::new(dec!(1000.00), "USD"),
582 ))
583 .with_posting(Posting::new(
584 "Income:Salary",
585 Amount::new(dec!(-1000.00), "USD"),
586 )),
587 ),
588 Directive::Balance(Balance::new(
589 date(2024, 1, 16),
590 "Assets:Bank",
591 Amount::new(dec!(500.00), "USD"), )),
593 ];
594
595 let errors = validate(&directives);
596 assert!(
597 errors
598 .iter()
599 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
600 );
601 }
602
603 #[test]
609 fn test_validate_balance_assertion_within_tolerance() {
610 let directives = vec![
615 Directive::Open(
616 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
617 ),
618 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
619 Directive::Transaction(
620 Transaction::new(date(2024, 1, 15), "Deposit")
621 .with_posting(Posting::new(
622 "Assets:Bank",
623 Amount::new(dec!(70.538), "ABC"), ))
625 .with_posting(Posting::new(
626 "Expenses:Misc",
627 Amount::new(dec!(-70.538), "ABC"),
628 )),
629 ),
630 Directive::Balance(Balance::new(
631 date(2024, 1, 16),
632 "Assets:Bank",
633 Amount::new(dec!(70.53), "ABC"), )),
635 ];
636
637 let errors = validate(&directives);
638 assert!(
639 errors.is_empty(),
640 "Balance within tolerance should pass: {errors:?}"
641 );
642 }
643
644 #[test]
646 fn test_validate_balance_assertion_exceeds_tolerance() {
647 let directives = vec![
652 Directive::Open(
653 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
654 ),
655 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
656 Directive::Transaction(
657 Transaction::new(date(2024, 1, 15), "Deposit")
658 .with_posting(Posting::new(
659 "Assets:Bank",
660 Amount::new(dec!(70.542), "ABC"),
661 ))
662 .with_posting(Posting::new(
663 "Expenses:Misc",
664 Amount::new(dec!(-70.542), "ABC"),
665 )),
666 ),
667 Directive::Balance(Balance::new(
668 date(2024, 1, 16),
669 "Assets:Bank",
670 Amount::new(dec!(70.53), "ABC"), )),
672 ];
673
674 let errors = validate(&directives);
675 assert!(
676 errors
677 .iter()
678 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
679 "Balance exceeding tolerance should fail"
680 );
681 }
682
683 #[test]
684 fn test_validate_unbalanced_transaction() {
685 let directives = vec![
686 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
687 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
688 Directive::Transaction(
689 Transaction::new(date(2024, 1, 15), "Unbalanced")
690 .with_posting(Posting::new(
691 "Assets:Bank",
692 Amount::new(dec!(-50.00), "USD"),
693 ))
694 .with_posting(Posting::new(
695 "Expenses:Food",
696 Amount::new(dec!(40.00), "USD"),
697 )), ),
699 ];
700
701 let errors = validate(&directives);
702 assert!(
703 errors
704 .iter()
705 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
706 );
707 }
708
709 #[test]
710 fn test_validate_currency_not_allowed() {
711 let directives = vec![
712 Directive::Open(
713 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
714 ),
715 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
716 Directive::Transaction(
717 Transaction::new(date(2024, 1, 15), "Test")
718 .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_posting(Posting::new(
720 "Income:Salary",
721 Amount::new(dec!(-100.00), "EUR"),
722 )),
723 ),
724 ];
725
726 let errors = validate(&directives);
727 assert!(
728 errors
729 .iter()
730 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
731 );
732 }
733
734 #[test]
735 fn test_validate_future_date_warning() {
736 let future_date = jiff::Zoned::now()
738 .date()
739 .checked_add(jiff::ToSpan::days(30))
740 .unwrap();
741
742 let directives = vec![Directive::Open(Open {
743 date: future_date,
744 account: "Assets:Bank".into(),
745 currencies: vec![],
746 booking: None,
747 meta: Default::default(),
748 })];
749
750 let errors = validate(&directives);
752 assert!(
753 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
754 "Should not warn about future dates by default"
755 );
756
757 let options = ValidationOptions {
759 warn_future_dates: true,
760 ..Default::default()
761 };
762 let errors = validate_with_options(&directives, options);
763 assert!(
764 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
765 "Should warn about future dates when enabled"
766 );
767 }
768
769 #[test]
770 fn test_validate_document_not_found() {
771 let directives = vec![
772 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
773 Directive::Document(Document {
774 date: date(2024, 1, 15),
775 account: "Assets:Bank".into(),
776 path: "/nonexistent/path/to/document.pdf".to_string(),
777 tags: vec![],
778 links: vec![],
779 meta: Default::default(),
780 }),
781 ];
782
783 let errors = validate(&directives);
785 assert!(
786 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
787 "Should check documents by default"
788 );
789
790 let options = ValidationOptions {
792 check_documents: false,
793 ..Default::default()
794 };
795 let errors = validate_with_options(&directives, options);
796 assert!(
797 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
798 "Should not report missing document when disabled"
799 );
800 }
801
802 #[test]
803 fn test_validate_document_account_not_open() {
804 let directives = vec![Directive::Document(Document {
805 date: date(2024, 1, 15),
806 account: "Assets:Unknown".into(),
807 path: "receipt.pdf".to_string(),
808 tags: vec![],
809 links: vec![],
810 meta: Default::default(),
811 })];
812
813 let errors = validate(&directives);
814 assert!(
815 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
816 "Should error for document on unopened account"
817 );
818 }
819
820 #[test]
821 fn test_error_code_is_warning() {
822 assert!(!ErrorCode::AccountNotOpen.is_warning());
823 assert!(!ErrorCode::DocumentNotFound.is_warning());
824 assert!(ErrorCode::FutureDate.is_warning());
825 }
826
827 #[test]
828 fn test_validate_pad_basic() {
829 let directives = vec![
830 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
831 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
832 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
833 Directive::Balance(Balance::new(
834 date(2024, 1, 2),
835 "Assets:Bank",
836 Amount::new(dec!(1000.00), "USD"),
837 )),
838 ];
839
840 let errors = validate(&directives);
841 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
843 }
844
845 #[test]
846 fn test_validate_pad_with_existing_balance() {
847 let directives = vec![
848 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
849 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
850 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
851 Directive::Transaction(
853 Transaction::new(date(2024, 1, 5), "Initial deposit")
854 .with_posting(Posting::new(
855 "Assets:Bank",
856 Amount::new(dec!(500.00), "USD"),
857 ))
858 .with_posting(Posting::new(
859 "Income:Salary",
860 Amount::new(dec!(-500.00), "USD"),
861 )),
862 ),
863 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
865 Directive::Balance(Balance::new(
866 date(2024, 1, 15),
867 "Assets:Bank",
868 Amount::new(dec!(1000.00), "USD"), )),
870 ];
871
872 let errors = validate(&directives);
873 assert!(
875 errors.is_empty(),
876 "Pad should add missing amount: {errors:?}"
877 );
878 }
879
880 #[test]
881 fn test_validate_pad_account_not_open() {
882 let directives = vec![
883 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
884 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
886 ];
887
888 let errors = validate(&directives);
889 assert!(
890 errors
891 .iter()
892 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
893 "Should error for pad on unopened account"
894 );
895 }
896
897 #[test]
898 fn test_validate_pad_source_not_open() {
899 let directives = vec![
900 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
901 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
903 ];
904
905 let errors = validate(&directives);
906 assert!(
907 errors.iter().any(
908 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
909 ),
910 "Should error for pad with unopened source account"
911 );
912 }
913
914 #[test]
915 fn test_validate_pad_negative_adjustment() {
916 let directives = vec![
918 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
919 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
920 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
921 Directive::Transaction(
923 Transaction::new(date(2024, 1, 5), "Big deposit")
924 .with_posting(Posting::new(
925 "Assets:Bank",
926 Amount::new(dec!(2000.00), "USD"),
927 ))
928 .with_posting(Posting::new(
929 "Income:Salary",
930 Amount::new(dec!(-2000.00), "USD"),
931 )),
932 ),
933 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
935 Directive::Balance(Balance::new(
936 date(2024, 1, 15),
937 "Assets:Bank",
938 Amount::new(dec!(1000.00), "USD"), )),
940 ];
941
942 let errors = validate(&directives);
943 assert!(
944 errors.is_empty(),
945 "Pad should handle negative adjustment: {errors:?}"
946 );
947 }
948
949 #[test]
950 fn test_validate_insufficient_units() {
951 use rustledger_core::CostSpec;
952
953 let cost_spec = CostSpec::empty()
954 .with_number_per(dec!(150))
955 .with_currency("USD");
956
957 let directives = vec![
958 Directive::Open(
959 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
960 ),
961 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
962 Directive::Transaction(
964 Transaction::new(date(2024, 1, 15), "Buy")
965 .with_posting(
966 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
967 .with_cost(cost_spec.clone()),
968 )
969 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
970 ),
971 Directive::Transaction(
973 Transaction::new(date(2024, 6, 1), "Sell too many")
974 .with_posting(
975 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
976 .with_cost(cost_spec),
977 )
978 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
979 ),
980 ];
981
982 let errors = validate(&directives);
983 assert!(
984 errors
985 .iter()
986 .any(|e| e.code == ErrorCode::InsufficientUnits),
987 "Should error for insufficient units: {errors:?}"
988 );
989 }
990
991 #[test]
992 fn test_validate_no_matching_lot() {
993 use rustledger_core::CostSpec;
994
995 let directives = vec![
996 Directive::Open(
997 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
998 ),
999 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1000 Directive::Transaction(
1002 Transaction::new(date(2024, 1, 15), "Buy")
1003 .with_posting(
1004 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1005 CostSpec::empty()
1006 .with_number_per(dec!(150))
1007 .with_currency("USD"),
1008 ),
1009 )
1010 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1011 ),
1012 Directive::Transaction(
1014 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1015 .with_posting(
1016 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1017 CostSpec::empty()
1018 .with_number_per(dec!(160))
1019 .with_currency("USD"),
1020 ),
1021 )
1022 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1023 ),
1024 ];
1025
1026 let errors = validate(&directives);
1027 assert!(
1028 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1029 "Should error for no matching lot: {errors:?}"
1030 );
1031 }
1032
1033 #[test]
1034 fn test_validate_multiple_lot_match_uses_fifo() {
1035 use rustledger_core::CostSpec;
1038
1039 let cost_spec = CostSpec::empty()
1040 .with_number_per(dec!(150))
1041 .with_currency("USD");
1042
1043 let directives = vec![
1044 Directive::Open(
1045 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1046 ),
1047 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1048 Directive::Transaction(
1050 Transaction::new(date(2024, 1, 15), "Buy lot 1")
1051 .with_posting(
1052 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1053 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1054 )
1055 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1056 ),
1057 Directive::Transaction(
1059 Transaction::new(date(2024, 2, 15), "Buy lot 2")
1060 .with_posting(
1061 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1062 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1063 )
1064 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1065 ),
1066 Directive::Transaction(
1068 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1069 .with_posting(
1070 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1071 .with_cost(cost_spec),
1072 )
1073 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1074 ),
1075 ];
1076
1077 let errors = validate(&directives);
1078 let booking_errors: Vec<_> = errors
1080 .iter()
1081 .filter(|e| {
1082 matches!(
1083 e.code,
1084 ErrorCode::InsufficientUnits
1085 | ErrorCode::NoMatchingLot
1086 | ErrorCode::AmbiguousLotMatch
1087 )
1088 })
1089 .collect();
1090 assert!(
1091 booking_errors.is_empty(),
1092 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1093 );
1094 }
1095
1096 #[test]
1097 fn test_validate_successful_booking() {
1098 use rustledger_core::CostSpec;
1099
1100 let cost_spec = CostSpec::empty()
1101 .with_number_per(dec!(150))
1102 .with_currency("USD");
1103
1104 let directives = vec![
1105 Directive::Open(
1106 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1107 ),
1108 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1109 Directive::Transaction(
1111 Transaction::new(date(2024, 1, 15), "Buy")
1112 .with_posting(
1113 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1114 .with_cost(cost_spec.clone()),
1115 )
1116 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1117 ),
1118 Directive::Transaction(
1120 Transaction::new(date(2024, 6, 1), "Sell")
1121 .with_posting(
1122 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1123 .with_cost(cost_spec),
1124 )
1125 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1126 ),
1127 ];
1128
1129 let errors = validate(&directives);
1130 let booking_errors: Vec<_> = errors
1132 .iter()
1133 .filter(|e| {
1134 matches!(
1135 e.code,
1136 ErrorCode::InsufficientUnits
1137 | ErrorCode::NoMatchingLot
1138 | ErrorCode::AmbiguousLotMatch
1139 )
1140 })
1141 .collect();
1142 assert!(
1143 booking_errors.is_empty(),
1144 "Should have no booking errors: {booking_errors:?}"
1145 );
1146 }
1147
1148 #[test]
1149 fn test_validate_account_already_open() {
1150 let directives = vec![
1151 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1152 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
1154
1155 let errors = validate(&directives);
1156 assert!(
1157 errors
1158 .iter()
1159 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1160 "Should error for duplicate open: {errors:?}"
1161 );
1162 }
1163
1164 #[test]
1165 fn test_validate_account_close_not_empty() {
1166 let directives = vec![
1167 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1168 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1169 Directive::Transaction(
1170 Transaction::new(date(2024, 1, 15), "Deposit")
1171 .with_posting(Posting::new(
1172 "Assets:Bank",
1173 Amount::new(dec!(100.00), "USD"),
1174 ))
1175 .with_posting(Posting::new(
1176 "Income:Salary",
1177 Amount::new(dec!(-100.00), "USD"),
1178 )),
1179 ),
1180 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
1182
1183 let errors = validate(&directives);
1184 assert!(
1185 errors
1186 .iter()
1187 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1188 "Should warn for closing account with balance: {errors:?}"
1189 );
1190 }
1191
1192 #[test]
1193 fn test_validate_no_postings_allowed() {
1194 let directives = vec![
1197 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1198 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1199 ];
1200
1201 let errors = validate(&directives);
1202 assert!(
1203 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1204 "Should NOT error for transaction with no postings: {errors:?}"
1205 );
1206 }
1207
1208 #[test]
1209 fn test_validate_single_posting() {
1210 let directives = vec![
1211 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1212 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1213 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1214 )),
1215 ];
1216
1217 let errors = validate(&directives);
1218 assert!(
1219 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1220 "Should warn for transaction with single posting: {errors:?}"
1221 );
1222 assert!(ErrorCode::SinglePosting.is_warning());
1224 }
1225
1226 #[test]
1227 fn test_validate_single_posting_zero_cost_no_warning() {
1228 let directives = vec![
1232 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1233 Directive::Transaction(
1234 Transaction::new(date(2024, 1, 15), "Grant").with_posting(
1235 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1236 rustledger_core::CostSpec::empty()
1237 .with_number_per(dec!(0))
1238 .with_currency("USD"),
1239 ),
1240 ),
1241 ),
1242 ];
1243
1244 let errors = validate(&directives);
1245 assert!(
1246 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1247 "Should NOT warn for zero-cost single posting: {errors:?}"
1248 );
1249 }
1250
1251 #[test]
1252 fn test_validate_single_posting_nonzero_cost_still_warns() {
1253 let directives = vec![
1255 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1256 Directive::Transaction(
1257 Transaction::new(date(2024, 1, 15), "Buy").with_posting(
1258 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1259 rustledger_core::CostSpec::empty()
1260 .with_number_per(dec!(150))
1261 .with_currency("USD"),
1262 ),
1263 ),
1264 ),
1265 ];
1266
1267 let errors = validate(&directives);
1268 assert!(
1269 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1270 "Should warn for single posting with non-zero cost: {errors:?}"
1271 );
1272 }
1273
1274 #[test]
1275 fn test_validate_pad_without_balance() {
1276 let directives = vec![
1277 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1278 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1279 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1280 ];
1282
1283 let errors = validate(&directives);
1284 assert!(
1285 errors
1286 .iter()
1287 .any(|e| e.code == ErrorCode::PadWithoutBalance),
1288 "Should error for pad without subsequent balance: {errors:?}"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_validate_multiple_pads_for_balance() {
1294 let directives = vec![
1295 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1296 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1297 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1298 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
1300 date(2024, 1, 3),
1301 "Assets:Bank",
1302 Amount::new(dec!(1000.00), "USD"),
1303 )),
1304 ];
1305
1306 let errors = validate(&directives);
1307 assert!(
1308 errors
1309 .iter()
1310 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1311 "Should error for multiple pads before balance: {errors:?}"
1312 );
1313 }
1314
1315 #[test]
1316 fn test_error_severity() {
1317 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1319 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1320 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1321
1322 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1324 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1325 assert_eq!(
1326 ErrorCode::AccountCloseNotEmpty.severity(),
1327 Severity::Warning
1328 );
1329
1330 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1332 }
1333
1334 #[test]
1335 fn test_validate_invalid_account_name() {
1336 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1338
1339 let errors = validate(&directives);
1340 assert!(
1341 errors
1342 .iter()
1343 .any(|e| e.code == ErrorCode::InvalidAccountName),
1344 "Should error for invalid account root: {errors:?}"
1345 );
1346 }
1347
1348 #[test]
1349 fn test_validate_account_lowercase_component() {
1350 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1352
1353 let errors = validate(&directives);
1354 assert!(
1355 errors
1356 .iter()
1357 .any(|e| e.code == ErrorCode::InvalidAccountName),
1358 "Should error for lowercase component: {errors:?}"
1359 );
1360 }
1361
1362 #[test]
1363 fn test_validate_valid_account_names() {
1364 let valid_names = [
1366 "Assets:Bank",
1367 "Assets:Bank:Checking",
1368 "Liabilities:CreditCard",
1369 "Equity:Opening-Balances",
1370 "Income:Salary2024",
1371 "Expenses:Food:Restaurant",
1372 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
1378
1379 for name in valid_names {
1380 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1381
1382 let errors = validate(&directives);
1383 let name_errors: Vec<_> = errors
1384 .iter()
1385 .filter(|e| e.code == ErrorCode::InvalidAccountName)
1386 .collect();
1387 assert!(
1388 name_errors.is_empty(),
1389 "Should accept valid account name '{name}': {name_errors:?}"
1390 );
1391 }
1392 }
1393
1394 #[test]
1399 fn test_e2002_balance_exceeds_explicit_tolerance() {
1400 let directives = vec![
1403 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1404 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1405 Directive::Transaction(
1406 Transaction::new(date(2024, 1, 15), "Deposit")
1407 .with_posting(Posting::new(
1408 "Assets:Bank",
1409 Amount::new(dec!(1000.00), "USD"),
1410 ))
1411 .with_posting(Posting::new(
1412 "Income:Salary",
1413 Amount::new(dec!(-1000.00), "USD"),
1414 )),
1415 ),
1416 Directive::Balance(
1419 Balance::new(
1420 date(2024, 1, 16),
1421 "Assets:Bank",
1422 Amount::new(dec!(999.00), "USD"),
1423 )
1424 .with_tolerance(dec!(0.01)),
1425 ),
1426 ];
1427
1428 let errors = validate(&directives);
1429
1430 assert!(
1431 errors
1432 .iter()
1433 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
1434 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
1435 );
1436 }
1437
1438 #[test]
1439 fn test_e2002_balance_within_explicit_tolerance_passes() {
1440 let directives = vec![
1442 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1443 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1444 Directive::Transaction(
1445 Transaction::new(date(2024, 1, 15), "Deposit")
1446 .with_posting(Posting::new(
1447 "Assets:Bank",
1448 Amount::new(dec!(1000.00), "USD"),
1449 ))
1450 .with_posting(Posting::new(
1451 "Income:Salary",
1452 Amount::new(dec!(-1000.00), "USD"),
1453 )),
1454 ),
1455 Directive::Balance(
1457 Balance::new(
1458 date(2024, 1, 16),
1459 "Assets:Bank",
1460 Amount::new(dec!(999.00), "USD"),
1461 )
1462 .with_tolerance(dec!(5.00)),
1463 ),
1464 ];
1465
1466 let errors = validate(&directives);
1467
1468 assert!(
1469 !errors
1470 .iter()
1471 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
1472 || e.code == ErrorCode::BalanceAssertionFailed),
1473 "Expected no balance errors, got: {errors:?}"
1474 );
1475 }
1476
1477 #[test]
1478 fn test_e5001_undeclared_currency() {
1479 use rustledger_core::Commodity;
1482
1483 let directives = vec![
1484 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1485 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1486 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1487 Directive::Transaction(
1488 Transaction::new(date(2024, 1, 15), "Lunch")
1489 .with_posting(Posting::new(
1490 "Expenses:Food",
1491 Amount::new(dec!(20.00), "EUR"), ))
1493 .with_posting(Posting::new(
1494 "Assets:Bank",
1495 Amount::new(dec!(-20.00), "EUR"),
1496 )),
1497 ),
1498 ];
1499
1500 let options = ValidationOptions {
1501 require_commodities: true,
1502 ..Default::default()
1503 };
1504 let errors = validate_with_options(&directives, options);
1505
1506 assert!(
1507 errors
1508 .iter()
1509 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1510 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_e5001_declared_currency_passes() {
1516 use rustledger_core::Commodity;
1518
1519 let directives = vec![
1520 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1521 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
1522 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1523 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1524 Directive::Transaction(
1525 Transaction::new(date(2024, 1, 15), "Lunch")
1526 .with_posting(Posting::new(
1527 "Expenses:Food",
1528 Amount::new(dec!(20.00), "EUR"),
1529 ))
1530 .with_posting(Posting::new(
1531 "Assets:Bank",
1532 Amount::new(dec!(-20.00), "EUR"),
1533 )),
1534 ),
1535 ];
1536
1537 let options = ValidationOptions {
1538 require_commodities: true,
1539 ..Default::default()
1540 };
1541 let errors = validate_with_options(&directives, options);
1542
1543 assert!(
1544 !errors
1545 .iter()
1546 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1547 "Expected no E5001 errors, got: {errors:?}"
1548 );
1549 }
1550
1551 #[test]
1552 fn test_e5001_not_raised_without_require_commodities() {
1553 let directives = vec![
1555 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1556 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1557 Directive::Transaction(
1558 Transaction::new(date(2024, 1, 15), "Lunch")
1559 .with_posting(Posting::new(
1560 "Expenses:Food",
1561 Amount::new(dec!(20.00), "XYZ"), ))
1563 .with_posting(Posting::new(
1564 "Assets:Bank",
1565 Amount::new(dec!(-20.00), "XYZ"),
1566 )),
1567 ),
1568 ];
1569
1570 let errors = validate(&directives);
1571
1572 assert!(
1573 !errors
1574 .iter()
1575 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1576 "Should not raise E5001 without require_commodities, got: {errors:?}"
1577 );
1578 }
1579
1580 #[test]
1581 fn test_e3002_multiple_missing_amounts() {
1582 let directives = vec![
1584 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1585 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1586 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
1587 Directive::Transaction(
1588 Transaction::new(date(2024, 1, 15), "Lunch")
1589 .with_posting(Posting::new(
1590 "Assets:Bank",
1591 Amount::new(dec!(-50.00), "USD"),
1592 ))
1593 .with_posting(Posting {
1595 account: "Expenses:Food".into(),
1596 units: None,
1597 cost: None,
1598 price: None,
1599 flag: None,
1600 meta: Default::default(),
1601 comments: vec![],
1602 trailing_comments: vec![],
1603 })
1604 .with_posting(Posting {
1605 account: "Expenses:Drinks".into(),
1606 units: None,
1607 cost: None,
1608 price: None,
1609 flag: None,
1610 meta: Default::default(),
1611 comments: vec![],
1612 trailing_comments: vec![],
1613 }),
1614 ),
1615 ];
1616
1617 let errors = validate(&directives);
1618
1619 assert!(
1620 errors
1621 .iter()
1622 .any(|e| e.code == ErrorCode::MultipleInterpolation),
1623 "Expected E3002 MultipleInterpolation, got: {errors:?}"
1624 );
1625 }
1626
1627 #[test]
1628 fn test_e3002_single_missing_amount_ok() {
1629 let directives = vec![
1631 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1632 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1633 Directive::Transaction(
1634 Transaction::new(date(2024, 1, 15), "Lunch")
1635 .with_posting(Posting::new(
1636 "Assets:Bank",
1637 Amount::new(dec!(-50.00), "USD"),
1638 ))
1639 .with_posting(Posting {
1640 account: "Expenses:Food".into(),
1641 units: None,
1642 cost: None,
1643 price: None,
1644 flag: None,
1645 meta: Default::default(),
1646 comments: vec![],
1647 trailing_comments: vec![],
1648 }),
1649 ),
1650 ];
1651
1652 let errors = validate(&directives);
1653
1654 assert!(
1655 !errors
1656 .iter()
1657 .any(|e| e.code == ErrorCode::MultipleInterpolation),
1658 "Should not raise E3002 with single missing amount, got: {errors:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_e7001_unknown_option() {
1664 let state = LedgerState::new();
1666 let mut errors = Vec::new();
1667
1668 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
1669
1670 assert_eq!(errors.len(), 1);
1671 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
1672 assert!(errors[0].message.contains("bogus_option"));
1673 }
1674
1675 #[test]
1676 fn test_e7002_invalid_option_value() {
1677 let state = LedgerState::new();
1678 let mut errors = Vec::new();
1679
1680 state.import_option_warnings(
1681 &[("E7002", "Invalid leaf account name: 'not-valid'")],
1682 &mut errors,
1683 );
1684
1685 assert_eq!(errors.len(), 1);
1686 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
1687 }
1688
1689 #[test]
1690 fn test_e7003_duplicate_option() {
1691 let state = LedgerState::new();
1692 let mut errors = Vec::new();
1693
1694 state.import_option_warnings(
1695 &[("E7003", "Option \"title\" can only be specified once")],
1696 &mut errors,
1697 );
1698
1699 assert_eq!(errors.len(), 1);
1700 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
1701 }
1702}