1#![forbid(unsafe_code)]
45#![warn(missing_docs)]
46
47mod error;
48mod validators;
49
50pub use error::{ErrorCode, Severity, ValidationError, is_advisory_only_code};
51pub use validators::balance::balance_tolerance;
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Phase {
80 Early,
84 Late,
88}
89
90use validators::{
91 register_open_late, validate_balance_early, validate_balance_late, validate_close,
92 validate_close_late, validate_document, validate_note, validate_open, validate_pad,
93 validate_transaction_early, validate_transaction_late,
94};
95
96use rayon::prelude::*;
97use rustledger_core::NaiveDate;
98
99const PARALLEL_SORT_THRESHOLD: usize = 5000;
102
103const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
107use rust_decimal::Decimal;
108use rustc_hash::{FxHashMap, FxHashSet};
109use rustledger_core::{Account, BookingMethod, Commodity, Currency, Directive, Inventory};
110use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
111use std::collections::BTreeSet;
112
113#[derive(Debug, Clone)]
115struct AccountState {
116 opened: NaiveDate,
118 closed: Option<NaiveDate>,
120 currencies: FxHashSet<rustledger_core::Currency>,
122 booking: BookingMethod,
125}
126
127#[non_exhaustive]
129#[derive(Debug, Clone)]
130pub struct ValidationOptions {
131 pub require_commodities: bool,
133 pub check_documents: bool,
135 pub warn_future_dates: bool,
137 pub document_base: Option<std::path::PathBuf>,
139 pub document_dirs: Vec<std::path::PathBuf>,
143 pub document_source_dirs: Vec<std::path::PathBuf>,
152 pub account_types: Vec<String>,
155 pub infer_tolerance_from_cost: bool,
158 pub tolerance_multiplier: Decimal,
161 pub inferred_tolerance_default: FxHashMap<String, Decimal>,
164 pub default_booking_method: BookingMethod,
174}
175
176impl Default for ValidationOptions {
177 fn default() -> Self {
178 Self {
179 require_commodities: false,
180 check_documents: true, warn_future_dates: false,
182 document_base: None,
183 document_dirs: Vec::new(),
184 document_source_dirs: Vec::new(),
185 account_types: vec![
186 "Assets".to_string(),
187 "Liabilities".to_string(),
188 "Equity".to_string(),
189 "Income".to_string(),
190 "Expenses".to_string(),
191 ],
192 infer_tolerance_from_cost: false,
194 tolerance_multiplier: Decimal::new(5, 1), inferred_tolerance_default: FxHashMap::default(),
196 default_booking_method: BookingMethod::default(),
197 }
198 }
199}
200
201impl ValidationOptions {
202 #[must_use]
204 pub fn with_account_types(mut self, types: Vec<String>) -> Self {
205 self.account_types = types;
206 self
207 }
208
209 #[must_use]
211 pub const fn with_require_commodities(mut self, require: bool) -> Self {
212 self.require_commodities = require;
213 self
214 }
215
216 #[must_use]
218 pub const fn with_check_documents(mut self, check: bool) -> Self {
219 self.check_documents = check;
220 self
221 }
222
223 #[must_use]
225 pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
226 self.warn_future_dates = warn;
227 self
228 }
229
230 #[must_use]
232 pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
233 self.document_dirs = dirs;
234 self
235 }
236
237 #[must_use]
241 pub fn with_document_source_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
242 self.document_source_dirs = dirs;
243 self
244 }
245
246 #[must_use]
248 pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
249 self.infer_tolerance_from_cost = infer;
250 self
251 }
252
253 #[must_use]
255 pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
256 self.tolerance_multiplier = multiplier;
257 self
258 }
259
260 #[must_use]
262 pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
263 self.inferred_tolerance_default = defaults;
264 self
265 }
266
267 #[must_use]
272 pub const fn with_default_booking_method(mut self, method: BookingMethod) -> Self {
273 self.default_booking_method = method;
274 self
275 }
276}
277
278#[derive(Debug, Clone)]
280struct PendingPad {
281 source_account: rustledger_core::Account,
283 date: NaiveDate,
285 padded_currencies: FxHashSet<rustledger_core::Currency>,
292 location: Option<(rustledger_parser::Span, u16)>,
296}
297
298#[derive(Debug, Clone)]
306pub struct BalanceActual {
307 pub date: NaiveDate,
309 pub account: Account,
311 pub currency: rustledger_core::Currency,
313 pub diff: rustledger_core::Decimal,
315}
316
317#[derive(Debug, Default)]
319pub struct LedgerState {
320 accounts: FxHashMap<rustledger_core::Account, AccountState>,
322 inventories: FxHashMap<rustledger_core::Account, Inventory>,
324 inventory_accounts: BTreeSet<Account>,
331 commodities: FxHashSet<rustledger_core::Currency>,
333 pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
335 options: ValidationOptions,
337 last_date: Option<NaiveDate>,
339 pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
350 pub(crate) account_not_open_early: FxHashSet<(u16, rustledger_core::Span)>,
362 pub(crate) lifecycle_deferred: FxHashSet<(u16, rustledger_core::Span, Account)>,
387 pub(crate) balance_actuals: Vec<BalanceActual>,
391}
392
393impl LedgerState {
394 #[must_use]
396 pub fn new() -> Self {
397 Self::default()
398 }
399
400 #[must_use]
402 pub fn with_options(options: ValidationOptions) -> Self {
403 Self {
404 options,
405 ..Default::default()
406 }
407 }
408
409 pub const fn set_require_commodities(&mut self, require: bool) {
411 self.options.require_commodities = require;
412 }
413
414 pub const fn set_check_documents(&mut self, check: bool) {
416 self.options.check_documents = check;
417 }
418
419 pub const fn set_warn_future_dates(&mut self, warn: bool) {
421 self.options.warn_future_dates = warn;
422 }
423
424 pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
426 self.options.document_base = Some(base.into());
427 }
428
429 #[must_use]
431 pub fn inventory(&self, account: &str) -> Option<&Inventory> {
432 self.inventories.get(account)
433 }
434
435 pub fn accounts(&self) -> impl Iterator<Item = &str> {
437 self.accounts.keys().map(rustledger_core::Account::as_str)
438 }
439
440 pub fn import_option_warnings(
448 &self,
449 warnings: &[(&str, &str)],
450 errors: &mut Vec<ValidationError>,
451 ) {
452 for &(code, message) in warnings {
453 let error_code = match code {
454 "E7001" => ErrorCode::UnknownOption,
455 "E7002" => ErrorCode::InvalidOptionValue,
456 "E7003" => ErrorCode::DuplicateOption,
457 _ => continue,
458 };
459 errors.push(ValidationError::new(
460 error_code,
461 message.to_string(),
462 NaiveDate::default(),
464 ));
465 }
466 }
467}
468
469trait ValidatableDirective: Sync {
478 fn directive(&self) -> &Directive;
479 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
483}
484
485impl ValidatableDirective for Directive {
486 fn directive(&self) -> &Directive {
487 self
488 }
489 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
490 None
491 }
492}
493
494impl ValidatableDirective for Spanned<Directive> {
495 fn directive(&self) -> &Directive {
496 &self.value
497 }
498 fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
499 Some((self.span, self.file_id))
500 }
501}
502
503fn sum_account_subtree(
517 inventories: &FxHashMap<Account, Inventory>,
518 index: &BTreeSet<Account>,
519 account: &Account,
520 currency: &Currency,
521) -> Decimal {
522 let acct = account.as_str();
523 let mut total = inventories
525 .get(account)
526 .map_or(Decimal::ZERO, |inv| inv.units(currency));
527 let mut lower = String::with_capacity(acct.len() + 1);
532 lower.push_str(acct);
533 lower.push(':');
534 let mut upper = String::with_capacity(acct.len() + 1);
535 upper.push_str(acct);
536 upper.push(';');
537 let bounds = (
538 std::ops::Bound::Included(lower.as_str()),
539 std::ops::Bound::Excluded(upper.as_str()),
540 );
541 for sub in index.range::<str, _>(bounds) {
542 if let Some(inv) = inventories.get(sub) {
543 total += inv.units(currency);
544 }
545 }
546 total
547}
548
549fn validate_phase_inner<D: ValidatableDirective>(
560 directives: &[D],
561 state: &mut LedgerState,
562 phase: Phase,
563 today: NaiveDate,
564) -> Vec<ValidationError> {
565 let document_exists_cache = if phase == Phase::Early {
568 build_document_exists_cache(directives, &state.options)
569 } else {
570 FxHashMap::default()
571 };
572
573 if phase == Phase::Early {
577 state.last_date = None;
578 }
579
580 let mut errors = Vec::new();
581
582 let mut keyed: Vec<(_, &D)> = directives
595 .iter()
596 .map(|d| (rustledger_core::booking_sort_key(d.directive()), d))
597 .collect();
598 if keyed.len() >= PARALLEL_SORT_THRESHOLD {
599 keyed.par_sort_by_key(|k| k.0);
600 } else {
601 keyed.sort_by_key(|k| k.0);
602 }
603
604 for (_, d) in keyed {
605 let directive = d.directive();
606 let date = directive.date();
607
608 let error_count_before = errors.len();
615
616 if phase == Phase::Early {
620 if let Some(last) = state.last_date
621 && date < last
622 {
623 errors.push(ValidationError::new(
624 ErrorCode::DateOutOfOrder,
625 format!("Directive date {date} is before previous directive {last}"),
626 date,
627 ));
628 }
629 state.last_date = Some(date);
630
631 if state.options.warn_future_dates && date > today {
632 errors.push(ValidationError::new(
633 ErrorCode::FutureDate,
634 format!("Entry dated in the future: {date}"),
635 date,
636 ));
637 }
638 }
639
640 match (phase, directive) {
641 (Phase::Early, Directive::Open(open)) => {
643 validate_open(state, open, &mut errors);
644 }
645 (Phase::Late, Directive::Open(open)) => {
649 register_open_late(state, open);
650 }
651 (Phase::Early, Directive::Close(close)) => {
652 validate_close(state, close, &mut errors);
653 }
654 (Phase::Late, Directive::Close(close)) => {
655 validate_close_late(state, close, &mut errors);
656 }
657 (Phase::Early, Directive::Commodity(comm)) => {
658 state.commodities.insert(comm.currency.clone());
659 validate_commodity_precision_meta(comm, &mut errors);
660 }
661 (Phase::Early, Directive::Pad(pad)) => {
662 validate_pad(state, pad, d.span_info(), &mut errors);
663 }
664 (Phase::Early, Directive::Document(doc)) => {
665 let file_id = d.span_info().map(|(_, fid)| fid);
666 validate_document(state, doc, file_id, &document_exists_cache, &mut errors);
667 }
668 (Phase::Early, Directive::Note(note)) => {
669 validate_note(state, note, &mut errors);
670 }
671 (Phase::Early, Directive::Transaction(txn)) => {
673 validate_transaction_early(state, txn, &mut errors);
674 }
675 (Phase::Late, Directive::Transaction(txn)) => {
676 validate_transaction_late(state, txn, &mut errors);
677 }
678 (Phase::Early, Directive::Balance(bal)) => {
679 validate_balance_early(state, bal, &mut errors);
680 }
681 (Phase::Late, Directive::Balance(bal)) => {
682 validate_balance_late(state, bal, &mut errors);
683 }
684 _ => {}
686 }
687
688 if let Some((span, file_id)) = d.span_info() {
695 for error in errors.iter_mut().skip(error_count_before) {
696 if error.span.is_none() {
697 error.span = Some(span);
698 error.file_id = Some(file_id);
699 }
700 if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
701 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
702 }
703 }
704 }
705 }
706
707 errors
708}
709
710const SYNTHESIZED_DIRECTIVE_NOTE: &str = "directive was synthesized by a plugin (no source location \
718 in your files); the responsible plugin is either an \
719 enabled auto-plugin (e.g. `auto_accounts`, or document \
720 discovery via `option \"documents\"`) or one of your \
721 `plugin \"…\"` declarations";
722
723fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
724 let mut errors = Vec::new();
725 for (target_account, pads) in &state.pending_pads {
726 for pad in pads {
727 if pad.padded_currencies.is_empty() {
728 let mut error = ValidationError::new(
729 ErrorCode::PadWithoutBalance,
730 "Unused Pad entry".to_string(),
731 pad.date,
732 )
733 .with_context(format!(
734 " {} pad {} {}",
735 pad.date, target_account, pad.source_account
736 ));
737 if let Some((span, file_id)) = pad.location {
743 error.span = Some(span);
744 error.file_id = Some(file_id);
745 if file_id == SYNTHESIZED_FILE_ID {
746 error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
747 }
748 }
749 errors.push(error);
750 }
751 }
752 }
753 errors
754}
755
756fn build_document_exists_cache<'a, D: ValidatableDirective>(
778 directives: &'a [D],
779 options: &ValidationOptions,
780) -> FxHashMap<(&'a str, Option<u16>), bool> {
781 if !options.check_documents {
782 return FxHashMap::default();
783 }
784
785 let mut keys: FxHashSet<(&str, Option<u16>)> = FxHashSet::default();
791 for d in directives {
792 if let Directive::Document(doc) = d.directive() {
793 let file_id = d.span_info().map(|(_, fid)| fid);
794 keys.insert((doc.path.as_str(), file_id));
795 }
796 }
797 let keys: Vec<(&str, Option<u16>)> = keys.into_iter().collect();
798
799 let resolve = |(s, file_id): (&'a str, Option<u16>)| {
806 ((s, file_id), document_file_exists(s, file_id, options))
807 };
808
809 if keys.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
810 keys.into_par_iter().map(resolve).collect()
811 } else {
812 keys.into_iter().map(resolve).collect()
813 }
814}
815
816fn document_file_exists(path: &str, file_id: Option<u16>, options: &ValidationOptions) -> bool {
826 let doc_path = std::path::Path::new(path);
827 if doc_path.is_absolute() {
828 doc_path.exists()
829 } else if let Some(base) = &options.document_base {
830 base.join(doc_path).exists()
831 } else if !options.document_dirs.is_empty() {
832 options
833 .document_dirs
834 .iter()
835 .any(|dir| dir.join(doc_path).exists())
836 } else if let Some(dir) = file_id.and_then(|id| options.document_source_dirs.get(id as usize)) {
837 dir.join(doc_path).exists()
838 } else {
839 doc_path.exists()
840 }
841}
842
843pub mod phase {
888 mod sealed {
889 pub trait Sealed {}
890 }
891
892 pub trait SessionPhase: sealed::Sealed {}
895
896 macro_rules! define_phase {
897 ($name:ident, $doc:expr) => {
898 #[doc = $doc]
899 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
900 pub struct $name;
901 impl sealed::Sealed for $name {}
902 impl SessionPhase for $name {}
903 };
904 }
905
906 define_phase!(
907 Pending,
908 "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
909 );
910 define_phase!(
911 EarlyDone,
912 "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
913 );
914 define_phase!(
915 LateDone,
916 "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
917 );
918}
919
920pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
921
922pub struct ValidationSession<P: SessionPhase = Pending> {
995 state: LedgerState,
996 _phase: std::marker::PhantomData<P>,
997}
998
999impl<P: SessionPhase> ValidationSession<P> {
1000 #[must_use]
1006 pub fn balance_actuals(&self) -> &[BalanceActual] {
1007 &self.state.balance_actuals
1008 }
1009}
1010
1011impl ValidationSession<Pending> {
1012 #[must_use]
1017 pub fn new(options: ValidationOptions) -> Self {
1018 Self {
1019 state: LedgerState::with_options(options),
1020 _phase: std::marker::PhantomData,
1021 }
1022 }
1023
1024 #[must_use = "ValidationSession::run_early returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1035 pub fn run_early(
1036 self,
1037 directives: &[Directive],
1038 today: NaiveDate,
1039 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1040 self.run_phase_internal(directives, Phase::Early, today)
1041 }
1042
1043 #[must_use = "ValidationSession::run_early_spanned returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1047 pub fn run_early_spanned(
1048 self,
1049 directives: &[Spanned<Directive>],
1050 today: NaiveDate,
1051 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1052 self.run_phase_internal(directives, Phase::Early, today)
1053 }
1054
1055 fn run_phase_internal<D: ValidatableDirective>(
1063 mut self,
1064 directives: &[D],
1065 phase: Phase,
1066 today: NaiveDate,
1067 ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1068 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1069 (
1070 ValidationSession {
1071 state: self.state,
1072 _phase: std::marker::PhantomData,
1073 },
1074 errors,
1075 )
1076 }
1077}
1078
1079impl ValidationSession<EarlyDone> {
1080 #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1090 pub fn run_late(
1091 self,
1092 directives: &[Directive],
1093 today: NaiveDate,
1094 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1095 self.run_phase_internal(directives, Phase::Late, today)
1096 }
1097
1098 #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1102 pub fn run_late_spanned(
1103 self,
1104 directives: &[Spanned<Directive>],
1105 today: NaiveDate,
1106 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1107 self.run_phase_internal(directives, Phase::Late, today)
1108 }
1109
1110 fn run_phase_internal<D: ValidatableDirective>(
1114 mut self,
1115 directives: &[D],
1116 phase: Phase,
1117 today: NaiveDate,
1118 ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1119 let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1120 (
1121 ValidationSession {
1122 state: self.state,
1123 _phase: std::marker::PhantomData,
1124 },
1125 errors,
1126 )
1127 }
1128}
1129
1130impl ValidationSession<LateDone> {
1131 #[must_use]
1135 pub fn finalize(self) -> Vec<ValidationError> {
1136 check_unused_pads(&self.state)
1137 }
1138}
1139
1140fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
1146 let Some(value) = comm.meta.get("precision") else {
1147 return;
1148 };
1149 if let Err(reason) = rustledger_core::parse_precision_meta(value) {
1150 errors.push(ValidationError::new(
1151 ErrorCode::InvalidPrecisionMetadata,
1152 format!(
1153 "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
1154 comm.currency
1155 ),
1156 comm.date,
1157 ));
1158 }
1159}
1160
1161#[cfg(test)]
1162mod tests {
1163 use super::*;
1164 use rust_decimal_macros::dec;
1165 use rustledger_core::{
1166 Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
1167 };
1168
1169 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1170 rustledger_core::naive_date(year, month, day).unwrap()
1171 }
1172
1173 fn test_today() -> NaiveDate {
1177 date(2030, 1, 1)
1178 }
1179
1180 fn validate(directives: &[Directive]) -> Vec<ValidationError> {
1185 validate_with_options(directives, ValidationOptions::default())
1186 }
1187
1188 fn validate_with_options(
1191 directives: &[Directive],
1192 options: ValidationOptions,
1193 ) -> Vec<ValidationError> {
1194 validate_with_today(directives, options, test_today())
1195 }
1196
1197 fn validate_with_today(
1201 directives: &[Directive],
1202 options: ValidationOptions,
1203 today: NaiveDate,
1204 ) -> Vec<ValidationError> {
1205 let session = ValidationSession::new(options);
1206 let (session, mut errors) = session.run_early(directives, today);
1207 let (session, late_errors) = session.run_late(directives, today);
1208 errors.extend(late_errors);
1209 errors.extend(session.finalize());
1210 errors
1211 }
1212
1213 #[test]
1214 fn sum_account_subtree_matches_scan_and_excludes_prefix_siblings() {
1215 let mut state = LedgerState::default();
1217 let fixture = [
1218 ("Assets:Bank", dec!(10)),
1219 ("Assets:Bank:Checking", dec!(40)),
1220 ("Assets:Bank:Savings", dec!(5)),
1221 ("Assets:BankAlias", dec!(99)), ("Assets:Other", dec!(7)),
1223 ];
1224 for (name, amt) in fixture {
1225 let acct = Account::from(name);
1226 let mut inv = Inventory::new();
1227 inv.add(rustledger_core::Position::simple(Amount::new(amt, "USD")));
1228 state.inventories.insert(acct.clone(), inv);
1229 state.inventory_accounts.insert(acct);
1230 }
1231
1232 let cur = Currency::from("USD");
1233 for name in [
1235 "Assets:Bank",
1236 "Assets:Bank:Checking",
1237 "Assets:BankAlias",
1238 "Assets:Other",
1239 "Assets:Missing",
1240 ] {
1241 let acct = Account::from(name);
1242 let indexed =
1243 sum_account_subtree(&state.inventories, &state.inventory_accounts, &acct, &cur);
1244 let scan =
1245 rustledger_core::sum_account_and_subaccounts(state.inventories.iter(), name, &cur);
1246 assert_eq!(indexed, scan, "indexed vs scan disagree for {name}");
1247 }
1248
1249 let bank = sum_account_subtree(
1251 &state.inventories,
1252 &state.inventory_accounts,
1253 &Account::from("Assets:Bank"),
1254 &cur,
1255 );
1256 assert_eq!(
1257 bank,
1258 dec!(55),
1259 "Assets:Bank must sum its subtree, excluding the Assets:BankAlias prefix sibling"
1260 );
1261 }
1262
1263 #[test]
1264 fn test_validate_account_lifecycle() {
1265 let directives = vec![
1266 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1267 Directive::Transaction(
1268 Transaction::new(date(2024, 1, 15), "Test")
1269 .with_synthesized_posting(Posting::new(
1270 "Assets:Bank",
1271 Amount::new(dec!(100), "USD"),
1272 ))
1273 .with_synthesized_posting(Posting::new(
1274 "Income:Salary",
1275 Amount::new(dec!(-100), "USD"),
1276 )),
1277 ),
1278 ];
1279
1280 let errors = validate(&directives);
1281
1282 assert!(errors
1284 .iter()
1285 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1286 }
1287
1288 #[test]
1289 fn test_validate_account_used_before_open() {
1290 let directives = vec![
1291 Directive::Transaction(
1292 Transaction::new(date(2024, 1, 1), "Test")
1293 .with_synthesized_posting(Posting::new(
1294 "Assets:Bank",
1295 Amount::new(dec!(100), "USD"),
1296 ))
1297 .with_synthesized_posting(Posting::new(
1298 "Income:Salary",
1299 Amount::new(dec!(-100), "USD"),
1300 )),
1301 ),
1302 Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1303 ];
1304
1305 let errors = validate(&directives);
1306
1307 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1308 }
1309
1310 #[test]
1311 fn test_validate_account_used_after_close() {
1312 let directives = vec![
1313 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1314 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1315 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1316 Directive::Transaction(
1317 Transaction::new(date(2024, 7, 1), "Test")
1318 .with_synthesized_posting(Posting::new(
1319 "Assets:Bank",
1320 Amount::new(dec!(-50), "USD"),
1321 ))
1322 .with_synthesized_posting(Posting::new(
1323 "Expenses:Food",
1324 Amount::new(dec!(50), "USD"),
1325 )),
1326 ),
1327 ];
1328
1329 let errors = validate(&directives);
1330
1331 assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1332 }
1333
1334 #[test]
1335 fn test_validate_balance_assertion() {
1336 let directives = vec![
1337 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1338 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1339 Directive::Transaction(
1340 Transaction::new(date(2024, 1, 15), "Deposit")
1341 .with_synthesized_posting(Posting::new(
1342 "Assets:Bank",
1343 Amount::new(dec!(1000.00), "USD"),
1344 ))
1345 .with_synthesized_posting(Posting::new(
1346 "Income:Salary",
1347 Amount::new(dec!(-1000.00), "USD"),
1348 )),
1349 ),
1350 Directive::Balance(Balance::new(
1351 date(2024, 1, 16),
1352 "Assets:Bank",
1353 Amount::new(dec!(1000.00), "USD"),
1354 )),
1355 ];
1356
1357 let errors = validate(&directives);
1358 assert!(errors.is_empty(), "{errors:?}");
1359 }
1360
1361 #[test]
1362 fn test_validate_balance_assertion_failed() {
1363 let directives = vec![
1364 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1365 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1366 Directive::Transaction(
1367 Transaction::new(date(2024, 1, 15), "Deposit")
1368 .with_synthesized_posting(Posting::new(
1369 "Assets:Bank",
1370 Amount::new(dec!(1000.00), "USD"),
1371 ))
1372 .with_synthesized_posting(Posting::new(
1373 "Income:Salary",
1374 Amount::new(dec!(-1000.00), "USD"),
1375 )),
1376 ),
1377 Directive::Balance(Balance::new(
1378 date(2024, 1, 16),
1379 "Assets:Bank",
1380 Amount::new(dec!(500.00), "USD"), )),
1382 ];
1383
1384 let errors = validate(&directives);
1385 assert!(
1386 errors
1387 .iter()
1388 .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1389 );
1390 }
1391
1392 #[test]
1398 fn test_validate_balance_assertion_within_tolerance() {
1399 let directives = vec![
1404 Directive::Open(
1405 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1406 ),
1407 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1408 Directive::Transaction(
1409 Transaction::new(date(2024, 1, 15), "Deposit")
1410 .with_synthesized_posting(Posting::new(
1411 "Assets:Bank",
1412 Amount::new(dec!(70.538), "ABC"), ))
1414 .with_synthesized_posting(Posting::new(
1415 "Expenses:Misc",
1416 Amount::new(dec!(-70.538), "ABC"),
1417 )),
1418 ),
1419 Directive::Balance(Balance::new(
1420 date(2024, 1, 16),
1421 "Assets:Bank",
1422 Amount::new(dec!(70.53), "ABC"), )),
1424 ];
1425
1426 let errors = validate(&directives);
1427 assert!(
1428 errors.is_empty(),
1429 "Balance within tolerance should pass: {errors:?}"
1430 );
1431 }
1432
1433 #[test]
1435 fn test_validate_balance_assertion_exceeds_tolerance() {
1436 let directives = vec![
1441 Directive::Open(
1442 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1443 ),
1444 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1445 Directive::Transaction(
1446 Transaction::new(date(2024, 1, 15), "Deposit")
1447 .with_synthesized_posting(Posting::new(
1448 "Assets:Bank",
1449 Amount::new(dec!(70.542), "ABC"),
1450 ))
1451 .with_synthesized_posting(Posting::new(
1452 "Expenses:Misc",
1453 Amount::new(dec!(-70.542), "ABC"),
1454 )),
1455 ),
1456 Directive::Balance(Balance::new(
1457 date(2024, 1, 16),
1458 "Assets:Bank",
1459 Amount::new(dec!(70.53), "ABC"), )),
1461 ];
1462
1463 let errors = validate(&directives);
1464 assert!(
1465 errors
1466 .iter()
1467 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1468 "Balance exceeding tolerance should fail"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_validate_unbalanced_transaction() {
1474 let directives = vec![
1475 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1476 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1477 Directive::Transaction(
1478 Transaction::new(date(2024, 1, 15), "Unbalanced")
1479 .with_synthesized_posting(Posting::new(
1480 "Assets:Bank",
1481 Amount::new(dec!(-50.00), "USD"),
1482 ))
1483 .with_synthesized_posting(Posting::new(
1484 "Expenses:Food",
1485 Amount::new(dec!(40.00), "USD"),
1486 )), ),
1488 ];
1489
1490 let errors = validate(&directives);
1491 assert!(
1492 errors
1493 .iter()
1494 .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1495 );
1496 }
1497
1498 #[test]
1499 fn test_validate_currency_not_allowed() {
1500 let directives = vec![
1501 Directive::Open(
1502 Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1503 ),
1504 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1505 Directive::Transaction(
1506 Transaction::new(date(2024, 1, 15), "Test")
1507 .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) .with_synthesized_posting(Posting::new(
1509 "Income:Salary",
1510 Amount::new(dec!(-100.00), "EUR"),
1511 )),
1512 ),
1513 ];
1514
1515 let errors = validate(&directives);
1516 assert!(
1517 errors
1518 .iter()
1519 .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1520 );
1521 }
1522
1523 #[test]
1524 fn test_validate_balance_wrong_currency() {
1525 let directives = vec![
1528 Directive::Open(
1529 Open::new(date(2024, 1, 1), "Assets:Cash").with_currencies(vec!["USD".into()]),
1530 ),
1531 Directive::Balance(Balance::new(
1532 date(2024, 3, 1),
1533 "Assets:Cash",
1534 Amount::new(dec!(100), "EUR"),
1535 )),
1536 ];
1537 let errors = validate(&directives);
1538 assert!(
1539 errors
1540 .iter()
1541 .any(|e| e.code == ErrorCode::CurrencyNotAllowed
1542 && e.message.contains("for Balance directive")),
1543 "balance in a non-allowed currency should be flagged (#1668); got: {:?}",
1544 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1545 );
1546 }
1547
1548 #[test]
1549 fn test_validate_balance_currency_allowed_when_unconstrained() {
1550 let directives = vec![
1553 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1554 Directive::Balance(Balance::new(
1555 date(2024, 3, 1),
1556 "Assets:Cash",
1557 Amount::new(dec!(0), "EUR"),
1558 )),
1559 ];
1560 let errors = validate(&directives);
1561 assert!(
1562 !errors
1563 .iter()
1564 .any(|e| e.code == ErrorCode::CurrencyNotAllowed),
1565 "unconstrained account must not flag balance currency (#1668); got: {:?}",
1566 errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1567 );
1568 }
1569
1570 #[test]
1571 fn test_validate_future_date_warning() {
1572 let today = date(2024, 1, 1);
1576 let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1577
1578 let directives = vec![Directive::Open(Open {
1579 date: future_date,
1580 account: "Assets:Bank".into(),
1581 currencies: vec![],
1582 booking: None,
1583 meta: Default::default(),
1584 })];
1585
1586 let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1588 assert!(
1589 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1590 "Should not warn about future dates by default"
1591 );
1592
1593 let options = ValidationOptions::default().with_warn_future_dates(true);
1595 let errors = validate_with_today(&directives, options, today);
1596 assert!(
1597 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1598 "Should warn about future dates when enabled"
1599 );
1600 }
1601
1602 #[test]
1609 fn test_validate_with_today_threads_today_parameter() {
1610 let directives = vec![Directive::Open(Open {
1611 date: date(2024, 6, 15),
1612 account: "Assets:Bank".into(),
1613 currencies: vec![],
1614 booking: None,
1615 meta: Default::default(),
1616 })];
1617 let options = ValidationOptions::default().with_warn_future_dates(true);
1618
1619 let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1621 assert!(
1622 errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1623 "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1624 );
1625
1626 let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1628 assert!(
1629 !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1630 "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_validate_document_not_found() {
1636 let directives = vec![
1637 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1638 Directive::Document(Document {
1639 date: date(2024, 1, 15),
1640 account: "Assets:Bank".into(),
1641 path: "/nonexistent/path/to/document.pdf".to_string(),
1642 tags: vec![],
1643 links: vec![],
1644 meta: Default::default(),
1645 }),
1646 ];
1647
1648 let errors = validate(&directives);
1650 assert!(
1651 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1652 "Should check documents by default"
1653 );
1654
1655 let options = ValidationOptions::default().with_check_documents(false);
1657 let errors = validate_with_options(&directives, options);
1658 assert!(
1659 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1660 "Should not report missing document when disabled"
1661 );
1662 }
1663
1664 #[test]
1665 fn test_validate_document_account_not_open() {
1666 let directives = vec![Directive::Document(Document {
1667 date: date(2024, 1, 15),
1668 account: "Assets:Unknown".into(),
1669 path: "receipt.pdf".to_string(),
1670 tags: vec![],
1671 links: vec![],
1672 meta: Default::default(),
1673 })];
1674
1675 let errors = validate(&directives);
1676 assert!(
1677 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1678 "Should error for document on unopened account"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_validate_document_relative_path_in_document_dirs() {
1684 let filename = "rustledger_test_889_relative_receipt.pdf";
1688 let dir = tempfile::tempdir().unwrap();
1689 let doc_subdir = dir.path().join("documents");
1690 std::fs::create_dir_all(&doc_subdir).unwrap();
1691 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1692
1693 let directives = vec![
1694 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1695 Directive::Document(Document {
1696 date: date(2024, 1, 15),
1697 account: "Assets:Bank".into(),
1698 path: filename.to_string(),
1699 tags: vec![],
1700 links: vec![],
1701 meta: Default::default(),
1702 }),
1703 ];
1704
1705 let errors = validate(&directives);
1707 assert!(
1708 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1709 "Should error when document_dirs not set"
1710 );
1711
1712 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1714 let errors = validate_with_options(&directives, options);
1715 assert!(
1716 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1717 "Should find document in document_dirs: {errors:?}"
1718 );
1719 }
1720
1721 #[test]
1722 fn test_validate_document_relative_path_not_found_in_dirs() {
1723 let filename = "rustledger_test_889_nonexistent.pdf";
1725 let dir = tempfile::tempdir().unwrap();
1726 let doc_subdir = dir.path().join("documents");
1727 std::fs::create_dir_all(&doc_subdir).unwrap();
1728
1729 let directives = vec![
1730 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1731 Directive::Document(Document {
1732 date: date(2024, 1, 15),
1733 account: "Assets:Bank".into(),
1734 path: filename.to_string(),
1735 tags: vec![],
1736 links: vec![],
1737 meta: Default::default(),
1738 }),
1739 ];
1740
1741 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1742 let errors = validate_with_options(&directives, options);
1743 assert!(
1744 errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1745 "Should error when file not found in any document_dir"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_validate_document_absolute_path_ignores_document_dirs() {
1751 let filename = "rustledger_test_889_absolute_receipt.pdf";
1752 let dir = tempfile::tempdir().unwrap();
1753 let doc_subdir = dir.path().join("documents");
1754 std::fs::create_dir_all(&doc_subdir).unwrap();
1755 std::fs::write(doc_subdir.join(filename), "test").unwrap();
1756
1757 let directives = vec![
1758 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1759 Directive::Document(Document {
1760 date: date(2024, 1, 15),
1761 account: "Assets:Bank".into(),
1762 path: doc_subdir.join(filename).display().to_string(),
1763 tags: vec![],
1764 links: vec![],
1765 meta: Default::default(),
1766 }),
1767 ];
1768
1769 let options = ValidationOptions::default()
1771 .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1772 let errors = validate_with_options(&directives, options);
1773 assert!(
1774 !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1775 "Absolute path should work even with wrong document_dirs: {errors:?}"
1776 );
1777 }
1778
1779 #[test]
1788 fn test_validate_document_parallel_batch_check() {
1789 let dir = tempfile::tempdir().unwrap();
1790 let doc_subdir = dir.path().join("docs");
1791 std::fs::create_dir_all(&doc_subdir).unwrap();
1792
1793 let mut directives: Vec<Directive> =
1796 vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1797 for i in 0..100 {
1798 let filename = format!("receipt_{i}.pdf");
1799 if i % 2 == 0 {
1800 std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1801 }
1802 directives.push(Directive::Document(Document {
1803 date: date(2024, 1, 15),
1804 account: "Assets:Bank".into(),
1805 path: filename,
1806 tags: vec![],
1807 links: vec![],
1808 meta: Default::default(),
1809 }));
1810 }
1811
1812 let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1813 let errors = validate_with_options(&directives, options);
1814
1815 let not_found_count = errors
1816 .iter()
1817 .filter(|e| e.code == ErrorCode::DocumentNotFound)
1818 .count();
1819 assert_eq!(
1820 not_found_count, 50,
1821 "exactly 50 of 100 documents should error as not-found"
1822 );
1823
1824 let example = errors
1828 .iter()
1829 .find(|e| e.code == ErrorCode::DocumentNotFound)
1830 .expect("should have at least one not-found error");
1831 assert!(
1832 example
1833 .context
1834 .as_deref()
1835 .is_some_and(|c| c.contains("searched")),
1836 "error context should mention the searched dirs, got: {:?}",
1837 example.context
1838 );
1839 }
1840
1841 #[test]
1842 fn test_error_code_is_warning() {
1843 assert!(!ErrorCode::AccountNotOpen.is_warning());
1844 assert!(!ErrorCode::DocumentNotFound.is_warning());
1845 assert!(ErrorCode::FutureDate.is_warning());
1846 }
1847
1848 #[test]
1849 fn test_validate_pad_basic() {
1850 let directives = vec![
1851 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1852 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1853 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1854 Directive::Balance(Balance::new(
1855 date(2024, 1, 2),
1856 "Assets:Bank",
1857 Amount::new(dec!(1000.00), "USD"),
1858 )),
1859 ];
1860
1861 let errors = validate(&directives);
1862 assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1864 }
1865
1866 #[test]
1867 fn test_validate_pad_with_existing_balance() {
1868 let directives = vec![
1869 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1870 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1871 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1872 Directive::Transaction(
1874 Transaction::new(date(2024, 1, 5), "Initial deposit")
1875 .with_synthesized_posting(Posting::new(
1876 "Assets:Bank",
1877 Amount::new(dec!(500.00), "USD"),
1878 ))
1879 .with_synthesized_posting(Posting::new(
1880 "Income:Salary",
1881 Amount::new(dec!(-500.00), "USD"),
1882 )),
1883 ),
1884 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1886 Directive::Balance(Balance::new(
1887 date(2024, 1, 15),
1888 "Assets:Bank",
1889 Amount::new(dec!(1000.00), "USD"), )),
1891 ];
1892
1893 let errors = validate(&directives);
1894 assert!(
1896 errors.is_empty(),
1897 "Pad should add missing amount: {errors:?}"
1898 );
1899 }
1900
1901 #[test]
1902 fn test_validate_pad_account_not_open() {
1903 let directives = vec![
1904 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1905 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1907 ];
1908
1909 let errors = validate(&directives);
1910 assert!(
1911 errors
1912 .iter()
1913 .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1914 "Should error for pad on unopened account"
1915 );
1916 }
1917
1918 #[test]
1919 fn test_validate_pad_source_not_open() {
1920 let directives = vec![
1921 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1922 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1924 ];
1925
1926 let errors = validate(&directives);
1927 assert!(
1928 errors.iter().any(
1929 |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1930 ),
1931 "Should error for pad with unopened source account"
1932 );
1933 }
1934
1935 #[test]
1936 fn test_validate_pad_negative_adjustment() {
1937 let directives = vec![
1939 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1940 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1941 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1942 Directive::Transaction(
1944 Transaction::new(date(2024, 1, 5), "Big deposit")
1945 .with_synthesized_posting(Posting::new(
1946 "Assets:Bank",
1947 Amount::new(dec!(2000.00), "USD"),
1948 ))
1949 .with_synthesized_posting(Posting::new(
1950 "Income:Salary",
1951 Amount::new(dec!(-2000.00), "USD"),
1952 )),
1953 ),
1954 Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1956 Directive::Balance(Balance::new(
1957 date(2024, 1, 15),
1958 "Assets:Bank",
1959 Amount::new(dec!(1000.00), "USD"), )),
1961 ];
1962
1963 let errors = validate(&directives);
1964 assert!(
1965 errors.is_empty(),
1966 "Pad should handle negative adjustment: {errors:?}"
1967 );
1968 }
1969
1970 #[test]
1971 fn test_validate_insufficient_units() {
1972 use rustledger_core::CostSpec;
1973
1974 let cost_spec = CostSpec::empty()
1975 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1976 .with_currency("USD");
1977
1978 let directives = vec![
1979 Directive::Open(
1980 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1981 ),
1982 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1983 Directive::Transaction(
1985 Transaction::new(date(2024, 1, 15), "Buy")
1986 .with_synthesized_posting(
1987 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1988 .with_cost(cost_spec.clone()),
1989 )
1990 .with_synthesized_posting(Posting::new(
1991 "Assets:Cash",
1992 Amount::new(dec!(-1500), "USD"),
1993 )),
1994 ),
1995 Directive::Transaction(
1997 Transaction::new(date(2024, 6, 1), "Sell too many")
1998 .with_synthesized_posting(
1999 Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
2000 .with_cost(cost_spec),
2001 )
2002 .with_synthesized_posting(Posting::new(
2003 "Assets:Cash",
2004 Amount::new(dec!(2250), "USD"),
2005 )),
2006 ),
2007 ];
2008
2009 let errors = validate(&directives);
2010 assert!(
2011 errors
2012 .iter()
2013 .any(|e| e.code == ErrorCode::InsufficientUnits),
2014 "Should error for insufficient units: {errors:?}"
2015 );
2016 }
2017
2018 #[test]
2019 fn test_validate_no_matching_lot() {
2020 use rustledger_core::CostSpec;
2021
2022 let directives = vec![
2023 Directive::Open(
2024 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2025 ),
2026 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2027 Directive::Transaction(
2029 Transaction::new(date(2024, 1, 15), "Buy")
2030 .with_synthesized_posting(
2031 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
2032 CostSpec::empty()
2033 .with_number(rustledger_core::CostNumber::PerUnit {
2034 value: dec!(150),
2035 })
2036 .with_currency("USD"),
2037 ),
2038 )
2039 .with_synthesized_posting(Posting::new(
2040 "Assets:Cash",
2041 Amount::new(dec!(-1500), "USD"),
2042 )),
2043 ),
2044 Directive::Transaction(
2046 Transaction::new(date(2024, 6, 1), "Sell at wrong price")
2047 .with_synthesized_posting(
2048 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
2049 CostSpec::empty()
2050 .with_number(rustledger_core::CostNumber::PerUnit {
2051 value: dec!(160),
2052 })
2053 .with_currency("USD"),
2054 ),
2055 )
2056 .with_synthesized_posting(Posting::new(
2057 "Assets:Cash",
2058 Amount::new(dec!(800), "USD"),
2059 )),
2060 ),
2061 ];
2062
2063 let errors = validate(&directives);
2064 assert!(
2065 errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
2066 "Should error for no matching lot: {errors:?}"
2067 );
2068 }
2069
2070 #[test]
2071 fn test_validate_multiple_lot_match_uses_fifo() {
2072 use rustledger_core::CostSpec;
2075
2076 let cost_spec = CostSpec::empty()
2077 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2078 .with_currency("USD");
2079
2080 let directives = vec![
2081 Directive::Open(
2082 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2083 ),
2084 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2085 Directive::Transaction(
2087 Transaction::new(date(2024, 1, 15), "Buy lot 1")
2088 .with_synthesized_posting(
2089 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2090 .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
2091 )
2092 .with_synthesized_posting(Posting::new(
2093 "Assets:Cash",
2094 Amount::new(dec!(-1500), "USD"),
2095 )),
2096 ),
2097 Directive::Transaction(
2099 Transaction::new(date(2024, 2, 15), "Buy lot 2")
2100 .with_synthesized_posting(
2101 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2102 .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
2103 )
2104 .with_synthesized_posting(Posting::new(
2105 "Assets:Cash",
2106 Amount::new(dec!(-1500), "USD"),
2107 )),
2108 ),
2109 Directive::Transaction(
2111 Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
2112 .with_synthesized_posting(
2113 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2114 .with_cost(cost_spec),
2115 )
2116 .with_synthesized_posting(Posting::new(
2117 "Assets:Cash",
2118 Amount::new(dec!(750), "USD"),
2119 )),
2120 ),
2121 ];
2122
2123 let errors = validate(&directives);
2124 let booking_errors: Vec<_> = errors
2126 .iter()
2127 .filter(|e| {
2128 matches!(
2129 e.code,
2130 ErrorCode::InsufficientUnits
2131 | ErrorCode::NoMatchingLot
2132 | ErrorCode::AmbiguousLotMatch
2133 )
2134 })
2135 .collect();
2136 assert!(
2137 booking_errors.is_empty(),
2138 "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
2139 );
2140 }
2141
2142 #[test]
2143 fn test_validate_successful_booking() {
2144 use rustledger_core::CostSpec;
2145
2146 let cost_spec = CostSpec::empty()
2147 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2148 .with_currency("USD");
2149
2150 let directives = vec![
2151 Directive::Open(
2152 Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2153 ),
2154 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2155 Directive::Transaction(
2157 Transaction::new(date(2024, 1, 15), "Buy")
2158 .with_synthesized_posting(
2159 Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2160 .with_cost(cost_spec.clone()),
2161 )
2162 .with_synthesized_posting(Posting::new(
2163 "Assets:Cash",
2164 Amount::new(dec!(-1500), "USD"),
2165 )),
2166 ),
2167 Directive::Transaction(
2169 Transaction::new(date(2024, 6, 1), "Sell")
2170 .with_synthesized_posting(
2171 Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2172 .with_cost(cost_spec),
2173 )
2174 .with_synthesized_posting(Posting::new(
2175 "Assets:Cash",
2176 Amount::new(dec!(750), "USD"),
2177 )),
2178 ),
2179 ];
2180
2181 let errors = validate(&directives);
2182 let booking_errors: Vec<_> = errors
2184 .iter()
2185 .filter(|e| {
2186 matches!(
2187 e.code,
2188 ErrorCode::InsufficientUnits
2189 | ErrorCode::NoMatchingLot
2190 | ErrorCode::AmbiguousLotMatch
2191 )
2192 })
2193 .collect();
2194 assert!(
2195 booking_errors.is_empty(),
2196 "Should have no booking errors: {booking_errors:?}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_validate_account_already_open() {
2202 let directives = vec![
2203 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2204 Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), ];
2206
2207 let errors = validate(&directives);
2208 assert!(
2209 errors
2210 .iter()
2211 .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2212 "Should error for duplicate open: {errors:?}"
2213 );
2214 }
2215
2216 #[test]
2217 fn test_validate_account_close_not_empty() {
2218 let directives = vec![
2219 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2220 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2221 Directive::Transaction(
2222 Transaction::new(date(2024, 1, 15), "Deposit")
2223 .with_synthesized_posting(Posting::new(
2224 "Assets:Bank",
2225 Amount::new(dec!(100.00), "USD"),
2226 ))
2227 .with_synthesized_posting(Posting::new(
2228 "Income:Salary",
2229 Amount::new(dec!(-100.00), "USD"),
2230 )),
2231 ),
2232 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), ];
2234
2235 let errors = validate(&directives);
2236 assert!(
2237 errors
2238 .iter()
2239 .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2240 "Should warn for closing account with balance: {errors:?}"
2241 );
2242 }
2243
2244 #[test]
2245 fn test_validate_no_postings_allowed() {
2246 let directives = vec![
2249 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2250 Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2251 ];
2252
2253 let errors = validate(&directives);
2254 assert!(
2255 !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2256 "Should NOT error for transaction with no postings: {errors:?}"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_validate_single_posting() {
2262 let directives = vec![
2263 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2264 Directive::Transaction(
2265 Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2266 Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2267 ),
2268 ),
2269 ];
2270
2271 let errors = validate(&directives);
2272 assert!(
2273 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2274 "Should warn for transaction with single posting: {errors:?}"
2275 );
2276 assert!(ErrorCode::SinglePosting.is_warning());
2278 }
2279
2280 #[test]
2281 fn test_validate_single_posting_zero_cost_no_warning() {
2282 let directives = vec![
2286 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2287 Directive::Transaction(
2288 Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2289 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2290 rustledger_core::CostSpec::empty()
2291 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2292 .with_currency("USD"),
2293 ),
2294 ),
2295 ),
2296 ];
2297
2298 let errors = validate(&directives);
2299 assert!(
2300 !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2301 "Should NOT warn for zero-cost single posting: {errors:?}"
2302 );
2303 }
2304
2305 #[test]
2306 fn test_validate_single_posting_nonzero_cost_still_warns() {
2307 let directives = vec![
2309 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2310 Directive::Transaction(
2311 Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2312 Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2313 rustledger_core::CostSpec::empty()
2314 .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2315 .with_currency("USD"),
2316 ),
2317 ),
2318 ),
2319 ];
2320
2321 let errors = validate(&directives);
2322 assert!(
2323 errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2324 "Should warn for single posting with non-zero cost: {errors:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_validate_pad_without_balance() {
2330 let directives = vec![
2331 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2332 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2333 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2334 ];
2336
2337 let errors = validate(&directives);
2338 assert!(
2339 errors
2340 .iter()
2341 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2342 "Should error for pad without subsequent balance: {errors:?}"
2343 );
2344 }
2345
2346 #[test]
2347 fn test_validate_multiple_pads_for_balance() {
2348 let directives = vec![
2349 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2350 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2351 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2352 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), Directive::Balance(Balance::new(
2354 date(2024, 1, 3),
2355 "Assets:Bank",
2356 Amount::new(dec!(1000.00), "USD"),
2357 )),
2358 ];
2359
2360 let errors = validate(&directives);
2361 assert!(
2362 errors
2363 .iter()
2364 .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2365 "Should error for multiple pads before balance: {errors:?}"
2366 );
2367 }
2368
2369 #[test]
2370 fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2371 let directives = vec![
2377 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2378 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2379 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2381 Directive::Balance(Balance::new(
2382 date(2024, 1, 2),
2383 "Assets:Bank",
2384 Amount::new(dec!(100.00), "USD"),
2385 )),
2386 Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2389 Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2390 Directive::Balance(Balance::new(
2391 date(2024, 2, 3),
2392 "Assets:Bank",
2393 Amount::new(dec!(200.00), "USD"),
2394 )),
2395 ];
2396
2397 let errors = validate(&directives);
2398 let multi_pad_count = errors
2399 .iter()
2400 .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2401 .count();
2402 assert_eq!(
2403 multi_pad_count, 1,
2404 "E2004 must fire exactly once on the second balance; got {errors:?}"
2405 );
2406 }
2407
2408 #[test]
2409 fn test_pad_serves_multi_currency_balances_on_same_day() {
2410 let directives = vec![
2417 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2418 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2419 Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2420 Directive::Balance(Balance::new(
2422 date(2024, 1, 2),
2423 "Assets:Bank",
2424 Amount::new(dec!(100.00), "USD"),
2425 )),
2426 Directive::Balance(Balance::new(
2427 date(2024, 1, 2),
2428 "Assets:Bank",
2429 Amount::new(dec!(50.00), "EUR"),
2430 )),
2431 ];
2432
2433 let errors = validate(&directives);
2434 assert!(
2435 !errors
2436 .iter()
2437 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2438 "pad should serve both USD and EUR; got {errors:?}"
2439 );
2440 assert!(
2441 !errors
2442 .iter()
2443 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2444 "pad serves at least one balance; should not be E2003; got {errors:?}"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2450 let directives = vec![
2455 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2456 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2457 Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2458 Directive::Balance(Balance::new(
2459 date(2024, 1, 2),
2460 "Assets:Bank",
2461 Amount::new(dec!(100.00), "USD"),
2462 )),
2463 ];
2464
2465 let errors = validate(&directives);
2466 assert!(
2470 errors
2471 .iter()
2472 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2473 "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2474 );
2475 assert!(
2477 errors
2478 .iter()
2479 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2480 "same-day pad never consumed; expected E2003; got {errors:?}"
2481 );
2482 }
2483
2484 #[test]
2485 fn test_future_pad_does_not_apply_to_earlier_balance() {
2486 let directives = vec![
2492 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2493 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2494 Directive::Balance(Balance::new(
2495 date(2024, 1, 2),
2496 "Assets:Bank",
2497 Amount::new(dec!(0.00), "USD"),
2498 )),
2499 Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2500 ];
2501
2502 let errors = validate(&directives);
2503 assert!(
2506 !errors
2507 .iter()
2508 .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2509 "future pad should not influence earlier balance; got {errors:?}"
2510 );
2511 assert!(
2513 errors
2514 .iter()
2515 .any(|e| e.code == ErrorCode::PadWithoutBalance),
2516 "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2517 );
2518 }
2519
2520 #[test]
2521 fn test_error_severity() {
2522 assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2524 assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2525 assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2526
2527 assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2529 assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2530 assert_eq!(
2531 ErrorCode::AccountCloseNotEmpty.severity(),
2532 Severity::Warning
2533 );
2534
2535 assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2537 }
2538
2539 #[test]
2540 fn test_validate_invalid_account_name() {
2541 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2543
2544 let errors = validate(&directives);
2545 assert!(
2546 errors
2547 .iter()
2548 .any(|e| e.code == ErrorCode::InvalidAccountName),
2549 "Should error for invalid account root: {errors:?}"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_validate_account_lowercase_component() {
2555 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2557
2558 let errors = validate(&directives);
2559 assert!(
2560 errors
2561 .iter()
2562 .any(|e| e.code == ErrorCode::InvalidAccountName),
2563 "Should error for lowercase component: {errors:?}"
2564 );
2565 }
2566
2567 #[test]
2568 fn test_validate_valid_account_names() {
2569 let valid_names = [
2571 "Assets:Bank",
2572 "Assets:Bank:Checking",
2573 "Liabilities:CreditCard",
2574 "Equity:Opening-Balances",
2575 "Income:Salary2024",
2576 "Expenses:Food:Restaurant",
2577 "Assets:401k", "Assets:沪深300", "Assets:Café", "Assets:日本銀行", "Assets:Капитал", ];
2583
2584 for name in valid_names {
2585 let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2586
2587 let errors = validate(&directives);
2588 let name_errors: Vec<_> = errors
2589 .iter()
2590 .filter(|e| e.code == ErrorCode::InvalidAccountName)
2591 .collect();
2592 assert!(
2593 name_errors.is_empty(),
2594 "Should accept valid account name '{name}': {name_errors:?}"
2595 );
2596 }
2597 }
2598
2599 #[test]
2604 fn test_e2002_balance_exceeds_explicit_tolerance() {
2605 let directives = vec![
2608 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2609 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2610 Directive::Transaction(
2611 Transaction::new(date(2024, 1, 15), "Deposit")
2612 .with_synthesized_posting(Posting::new(
2613 "Assets:Bank",
2614 Amount::new(dec!(1000.00), "USD"),
2615 ))
2616 .with_synthesized_posting(Posting::new(
2617 "Income:Salary",
2618 Amount::new(dec!(-1000.00), "USD"),
2619 )),
2620 ),
2621 Directive::Balance(
2624 Balance::new(
2625 date(2024, 1, 16),
2626 "Assets:Bank",
2627 Amount::new(dec!(999.00), "USD"),
2628 )
2629 .with_tolerance(dec!(0.01)),
2630 ),
2631 ];
2632
2633 let errors = validate(&directives);
2634
2635 assert!(
2636 errors
2637 .iter()
2638 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2639 "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2640 );
2641 }
2642
2643 #[test]
2644 fn test_e2002_balance_within_explicit_tolerance_passes() {
2645 let directives = vec![
2647 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2648 Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2649 Directive::Transaction(
2650 Transaction::new(date(2024, 1, 15), "Deposit")
2651 .with_synthesized_posting(Posting::new(
2652 "Assets:Bank",
2653 Amount::new(dec!(1000.00), "USD"),
2654 ))
2655 .with_synthesized_posting(Posting::new(
2656 "Income:Salary",
2657 Amount::new(dec!(-1000.00), "USD"),
2658 )),
2659 ),
2660 Directive::Balance(
2662 Balance::new(
2663 date(2024, 1, 16),
2664 "Assets:Bank",
2665 Amount::new(dec!(999.00), "USD"),
2666 )
2667 .with_tolerance(dec!(5.00)),
2668 ),
2669 ];
2670
2671 let errors = validate(&directives);
2672
2673 assert!(
2674 !errors
2675 .iter()
2676 .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2677 || e.code == ErrorCode::BalanceAssertionFailed),
2678 "Expected no balance errors, got: {errors:?}"
2679 );
2680 }
2681
2682 #[test]
2683 fn test_e5001_undeclared_currency() {
2684 use rustledger_core::Commodity;
2687
2688 let directives = vec![
2689 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2690 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2691 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2692 Directive::Transaction(
2693 Transaction::new(date(2024, 1, 15), "Lunch")
2694 .with_synthesized_posting(Posting::new(
2695 "Expenses:Food",
2696 Amount::new(dec!(20.00), "EUR"), ))
2698 .with_synthesized_posting(Posting::new(
2699 "Assets:Bank",
2700 Amount::new(dec!(-20.00), "EUR"),
2701 )),
2702 ),
2703 ];
2704
2705 let options = ValidationOptions::default().with_require_commodities(true);
2706 let errors = validate_with_options(&directives, options);
2707
2708 assert!(
2709 errors
2710 .iter()
2711 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2712 "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2713 );
2714 }
2715
2716 #[test]
2717 fn test_e5001_declared_currency_passes() {
2718 use rustledger_core::Commodity;
2720
2721 let directives = vec![
2722 Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2723 Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2724 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2725 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2726 Directive::Transaction(
2727 Transaction::new(date(2024, 1, 15), "Lunch")
2728 .with_synthesized_posting(Posting::new(
2729 "Expenses:Food",
2730 Amount::new(dec!(20.00), "EUR"),
2731 ))
2732 .with_synthesized_posting(Posting::new(
2733 "Assets:Bank",
2734 Amount::new(dec!(-20.00), "EUR"),
2735 )),
2736 ),
2737 ];
2738
2739 let options = ValidationOptions::default().with_require_commodities(true);
2740 let errors = validate_with_options(&directives, options);
2741
2742 assert!(
2743 !errors
2744 .iter()
2745 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2746 "Expected no E5001 errors, got: {errors:?}"
2747 );
2748 }
2749
2750 #[test]
2751 fn test_e5001_not_raised_without_require_commodities() {
2752 let directives = vec![
2754 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2755 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2756 Directive::Transaction(
2757 Transaction::new(date(2024, 1, 15), "Lunch")
2758 .with_synthesized_posting(Posting::new(
2759 "Expenses:Food",
2760 Amount::new(dec!(20.00), "XYZ"), ))
2762 .with_synthesized_posting(Posting::new(
2763 "Assets:Bank",
2764 Amount::new(dec!(-20.00), "XYZ"),
2765 )),
2766 ),
2767 ];
2768
2769 let errors = validate(&directives);
2770
2771 assert!(
2772 !errors
2773 .iter()
2774 .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2775 "Should not raise E5001 without require_commodities, got: {errors:?}"
2776 );
2777 }
2778
2779 #[test]
2780 fn test_e3002_multiple_missing_amounts() {
2781 let directives = vec![
2783 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2784 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2785 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2786 Directive::Transaction(
2787 Transaction::new(date(2024, 1, 15), "Lunch")
2788 .with_synthesized_posting(Posting::new(
2789 "Assets:Bank",
2790 Amount::new(dec!(-50.00), "USD"),
2791 ))
2792 .with_synthesized_posting(Posting {
2794 account: "Expenses:Food".into(),
2795 units: None,
2796 cost: None,
2797 price: None,
2798 flag: None,
2799 meta: Default::default(),
2800 comments: vec![],
2801 trailing_comments: vec![],
2802 })
2803 .with_synthesized_posting(Posting {
2804 account: "Expenses:Drinks".into(),
2805 units: None,
2806 cost: None,
2807 price: None,
2808 flag: None,
2809 meta: Default::default(),
2810 comments: vec![],
2811 trailing_comments: vec![],
2812 }),
2813 ),
2814 ];
2815
2816 let errors = validate(&directives);
2817
2818 assert!(
2819 errors
2820 .iter()
2821 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2822 "Expected E3002 MultipleInterpolation, got: {errors:?}"
2823 );
2824 }
2825
2826 #[test]
2827 fn test_e3002_single_missing_amount_ok() {
2828 let directives = vec![
2830 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2831 Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2832 Directive::Transaction(
2833 Transaction::new(date(2024, 1, 15), "Lunch")
2834 .with_synthesized_posting(Posting::new(
2835 "Assets:Bank",
2836 Amount::new(dec!(-50.00), "USD"),
2837 ))
2838 .with_synthesized_posting(Posting {
2839 account: "Expenses:Food".into(),
2840 units: None,
2841 cost: None,
2842 price: None,
2843 flag: None,
2844 meta: Default::default(),
2845 comments: vec![],
2846 trailing_comments: vec![],
2847 }),
2848 ),
2849 ];
2850
2851 let errors = validate(&directives);
2852
2853 assert!(
2854 !errors
2855 .iter()
2856 .any(|e| e.code == ErrorCode::MultipleInterpolation),
2857 "Should not raise E3002 with single missing amount, got: {errors:?}"
2858 );
2859 }
2860
2861 #[test]
2862 fn test_e7001_unknown_option() {
2863 let state = LedgerState::new();
2865 let mut errors = Vec::new();
2866
2867 state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2868
2869 assert_eq!(errors.len(), 1);
2870 assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2871 assert!(errors[0].message.contains("bogus_option"));
2872 }
2873
2874 #[test]
2875 fn test_e7002_invalid_option_value() {
2876 let state = LedgerState::new();
2877 let mut errors = Vec::new();
2878
2879 state.import_option_warnings(
2880 &[("E7002", "Invalid leaf account name: 'not-valid'")],
2881 &mut errors,
2882 );
2883
2884 assert_eq!(errors.len(), 1);
2885 assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2886 }
2887
2888 #[test]
2889 fn test_e7003_duplicate_option() {
2890 let state = LedgerState::new();
2891 let mut errors = Vec::new();
2892
2893 state.import_option_warnings(
2894 &[("E7003", "Option \"title\" can only be specified once")],
2895 &mut errors,
2896 );
2897
2898 assert_eq!(errors.len(), 1);
2899 assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2900 }
2901
2902 fn commodity_with_precision(value: MetaValue) -> Directive {
2905 let mut meta = rustledger_core::Metadata::default();
2906 meta.insert("precision".into(), value);
2907 Directive::Commodity(
2908 rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2909 )
2910 }
2911
2912 #[test]
2913 fn precision_meta_valid_integer_emits_no_warning() {
2914 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2915 let errors = validate(&directives);
2916 assert!(
2917 errors
2918 .iter()
2919 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2920 "valid precision must not produce a warning, got: {errors:?}"
2921 );
2922 }
2923
2924 #[test]
2925 fn precision_meta_zero_is_valid() {
2926 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2927 let errors = validate(&directives);
2928 assert!(
2929 errors
2930 .iter()
2931 .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2932 );
2933 }
2934
2935 #[test]
2936 fn precision_meta_negative_emits_e5003() {
2937 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2938 let errors = validate(&directives);
2939 let warnings: Vec<_> = errors
2940 .iter()
2941 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2942 .collect();
2943 assert_eq!(warnings.len(), 1, "expected one E5003");
2944 assert_eq!(warnings[0].code.severity(), Severity::Warning);
2945 assert!(warnings[0].message.contains("non-negative"));
2946 }
2947
2948 #[test]
2949 fn precision_meta_non_integer_emits_e5003() {
2950 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2951 let errors = validate(&directives);
2952 let warnings: Vec<_> = errors
2953 .iter()
2954 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2955 .collect();
2956 assert_eq!(warnings.len(), 1);
2957 assert!(warnings[0].message.contains("integer"));
2958 }
2959
2960 #[test]
2961 fn precision_meta_string_value_emits_e5003() {
2962 let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2963 let errors = validate(&directives);
2964 let warnings: Vec<_> = errors
2965 .iter()
2966 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2967 .collect();
2968 assert_eq!(warnings.len(), 1);
2969 assert!(warnings[0].message.contains("string"));
2970 }
2971
2972 #[test]
2973 fn precision_meta_out_of_u32_range_emits_e5003() {
2974 let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2976 8589934592
2977 )))];
2978 let errors = validate(&directives);
2979 let warnings: Vec<_> = errors
2980 .iter()
2981 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2982 .collect();
2983 assert_eq!(warnings.len(), 1);
2984 assert!(warnings[0].message.contains("exceeds"));
2985 }
2986
2987 #[test]
2988 fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2989 let directives = vec![
2994 commodity_with_precision(MetaValue::Number(dec!(2))),
2995 commodity_with_precision(MetaValue::Number(dec!(-1))),
2996 ];
2997 let warnings: Vec<_> = validate(&directives)
2998 .into_iter()
2999 .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
3000 .collect();
3001 assert_eq!(
3002 warnings.len(),
3003 1,
3004 "exactly one E5003 expected (only the invalid declaration)"
3005 );
3006 assert!(warnings[0].message.contains("non-negative"));
3007 }
3008
3009 #[test]
3010 fn precision_meta_e5003_is_warning_severity() {
3011 assert_eq!(
3015 ErrorCode::InvalidPrecisionMetadata.severity(),
3016 Severity::Warning
3017 );
3018 assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
3019 }
3020
3021 #[test]
3029 fn test_validate_early_emits_e1001_on_elided_posting() {
3030 let directives = vec![
3031 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3032 Directive::Transaction(
3033 Transaction::new(date(2024, 1, 15), "Zero to unopened")
3034 .with_synthesized_posting(Posting::new(
3035 "Assets:Bank",
3036 Amount::new(dec!(0.00), "USD"),
3037 ))
3038 .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
3039 ),
3040 ];
3041
3042 let session = ValidationSession::new(ValidationOptions::default());
3043 let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
3044
3045 assert!(
3046 errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
3047 && e.to_string().contains("Expenses:NeverOpened")),
3048 "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
3049 );
3050 }
3051
3052 #[test]
3056 fn test_validate_late_does_not_duplicate_e1001() {
3057 let directives = vec![
3058 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3059 Directive::Transaction(
3060 Transaction::new(date(2024, 1, 15), "To unopened")
3061 .with_synthesized_posting(Posting::new(
3062 "Assets:Bank",
3063 Amount::new(dec!(100), "USD"),
3064 ))
3065 .with_synthesized_posting(Posting::new(
3066 "Expenses:NeverOpened",
3067 Amount::new(dec!(-100), "USD"),
3068 )),
3069 ),
3070 ];
3071
3072 let session = ValidationSession::new(ValidationOptions::default());
3073 let (session, early) = session.run_early(&directives, date(2026, 1, 1));
3074 let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
3075
3076 let early_e1001 = early
3077 .iter()
3078 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3079 .count();
3080 let late_e1001 = late
3081 .iter()
3082 .filter(|e| e.code == ErrorCode::AccountNotOpen)
3083 .count();
3084
3085 assert_eq!(
3086 early_e1001, 0,
3087 "explicit posting: early phase defers E1001 to late; got: {early:?}"
3088 );
3089 assert_eq!(
3090 late_e1001, 1,
3091 "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
3092 );
3093 }
3094
3095 #[test]
3101 fn test_validate_chained_matches_explicit_phases() {
3102 let directives = vec![
3106 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3107 Directive::Transaction(
3108 Transaction::new(date(2024, 1, 15), "Mixed")
3109 .with_synthesized_posting(Posting::new(
3110 "Assets:Bank",
3111 Amount::new(dec!(50), "USD"),
3112 ))
3113 .with_synthesized_posting(Posting::new(
3114 "Income:Salary",
3115 Amount::new(dec!(-50), "USD"),
3116 )),
3117 ),
3118 Directive::Balance(Balance::new(
3119 date(2024, 1, 16),
3120 "Assets:Bank",
3121 Amount::new(dec!(50), "USD"),
3122 )),
3123 ];
3124
3125 let chained = validate(&directives);
3127
3128 let session = ValidationSession::new(ValidationOptions::default());
3130 let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3131 let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3132 explicit.extend(late_errs);
3133 explicit.extend(session.finalize());
3134
3135 let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3139 let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3140 assert_eq!(
3141 chained_strs, explicit_strs,
3142 "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3143 );
3144 }
3145
3146 #[test]
3147 fn test_phase_order_early_then_late_then_finalize() {
3148 let directives = vec![
3155 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3156 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3157 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3158 Directive::Transaction(
3160 Transaction::new(date(2024, 1, 5), "early")
3161 .with_synthesized_posting(Posting::new(
3162 "Assets:Bank",
3163 Amount::new(dec!(100), "USD"),
3164 ))
3165 .with_synthesized_posting(Posting::new(
3166 "Income:Salary",
3167 Amount::new(dec!(-100), "USD"),
3168 )),
3169 ),
3170 Directive::Pad(Pad::new(
3172 date(2024, 1, 10),
3173 "Assets:Other",
3174 "Equity:Opening",
3175 )),
3176 Directive::Balance(Balance::new(
3178 date(2024, 2, 1),
3179 "Assets:Bank",
3180 Amount::new(dec!(999), "USD"),
3181 )),
3182 ];
3183
3184 let errors = validate(&directives);
3185 let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3186
3187 let early_pos = codes
3188 .iter()
3189 .position(|c| *c == ErrorCode::AccountNotOpen)
3190 .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3191 let late_pos = codes
3192 .iter()
3193 .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3194 .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3195 let finalize_pos = codes
3196 .iter()
3197 .position(|c| *c == ErrorCode::PadWithoutBalance)
3198 .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3199
3200 assert!(
3201 early_pos < late_pos,
3202 "early-phase errors must precede late-phase; got {codes:?}"
3203 );
3204 assert!(
3205 late_pos < finalize_pos,
3206 "late-phase errors must precede finalize; got {codes:?}"
3207 );
3208 }
3209
3210 #[test]
3211 fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3212 let directives = vec![
3219 Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3220 Directive::Transaction(
3223 Transaction::new(date(2024, 1, 10), "leave residue")
3224 .with_synthesized_posting(Posting::new(
3225 "Assets:Bank",
3226 Amount::new(dec!(50), "USD"),
3227 ))
3228 .with_synthesized_posting(Posting::new(
3229 "Equity:Opening",
3230 Amount::new(dec!(-50), "USD"),
3231 )),
3232 ),
3233 Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3234 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3235 Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3236 ];
3237
3238 let errors = validate(&directives);
3239 let close_not_empty_count = errors
3240 .iter()
3241 .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3242 .count();
3243 assert_eq!(
3244 close_not_empty_count, 1,
3245 "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3246 );
3247 let account_closed_count = errors
3249 .iter()
3250 .filter(|e| e.code == ErrorCode::AccountClosed)
3251 .count();
3252 assert_eq!(
3253 account_closed_count, 1,
3254 "duplicate close should still report AccountClosed once; got {errors:?}"
3255 );
3256 }
3257
3258 #[test]
3284 fn typestate_pins_phase_ordering_at_compile_time() {
3285 fn _expect_pending_returns_early(
3297 s: ValidationSession<Pending>,
3298 ) -> ValidationSession<EarlyDone> {
3299 let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3300 s
3301 }
3302 fn _expect_early_returns_late(
3303 s: ValidationSession<EarlyDone>,
3304 ) -> ValidationSession<LateDone> {
3305 let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3306 s
3307 }
3308 fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3309 s.finalize()
3310 }
3311 }
3312
3313 fn open_at(d: NaiveDate, account: &str) -> Directive {
3322 Directive::Open(Open::new(d, account))
3323 }
3324
3325 fn txn_at(d: NaiveDate, postings: Vec<Posting>) -> Directive {
3331 let mut t = Transaction::new(d, "t");
3332 for (i, p) in postings.into_iter().enumerate() {
3333 let start = (d.day() as usize) * 100 + i * 10;
3334 t = t.with_posting(rustledger_core::Spanned::new(
3335 p,
3336 rustledger_core::Span::new(start, start + 9),
3337 ));
3338 }
3339 Directive::Transaction(t)
3340 }
3341
3342 #[test]
3343 fn explicit_posting_before_open_is_flagged_exactly_once() {
3344 let directives = vec![
3345 open_at(date(2020, 1, 1), "Equity:Opening"),
3346 txn_at(
3347 date(2020, 1, 15),
3348 vec![
3349 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3350 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3351 ],
3352 ),
3353 open_at(date(2020, 2, 1), "Assets:Bank"),
3354 ];
3355 let errors = validate(&directives);
3356 let hits: Vec<_> = errors
3357 .iter()
3358 .filter(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank"))
3359 .collect();
3360 assert_eq!(
3361 hits.len(),
3362 1,
3363 "use-before-open must be reported exactly once: {errors:?}"
3364 );
3365 assert!(
3366 hits[0].message.contains("not opened until 2020-02-01"),
3367 "error should carry the open date: {}",
3368 hits[0].message
3369 );
3370 }
3371
3372 #[test]
3373 fn elided_posting_before_open_is_flagged_exactly_once() {
3374 let directives = vec![
3377 open_at(date(2020, 1, 1), "Equity:Opening"),
3378 txn_at(
3379 date(2020, 1, 15),
3380 vec![
3381 Posting {
3382 account: "Assets:Bank".into(),
3383 units: None,
3384 cost: None,
3385 price: None,
3386 flag: None,
3387 meta: Default::default(),
3388 comments: vec![],
3389 trailing_comments: vec![],
3390 },
3391 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3392 ],
3393 ),
3394 open_at(date(2020, 2, 1), "Assets:Bank"),
3395 ];
3396 let errors = validate(&directives);
3397 let hits = errors
3398 .iter()
3399 .filter(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank"))
3400 .count();
3401 assert_eq!(
3402 hits, 1,
3403 "elided-before-open must not double-report: {errors:?}"
3404 );
3405 }
3406
3407 #[test]
3408 fn posting_after_close_is_flagged_exactly_once() {
3409 let directives = vec![
3413 open_at(date(2020, 1, 1), "Assets:Bank"),
3414 open_at(date(2020, 1, 1), "Equity:Opening"),
3415 Directive::Close(Close::new(date(2020, 2, 1), "Assets:Bank")),
3416 txn_at(
3417 date(2020, 3, 1),
3418 vec![
3419 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3420 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3421 ],
3422 ),
3423 ];
3424 let errors = validate(&directives);
3425 let hits = errors
3426 .iter()
3427 .filter(|e| e.code == ErrorCode::AccountClosed)
3428 .count();
3429 assert_eq!(
3430 hits, 1,
3431 "after-close must be reported exactly once: {errors:?}"
3432 );
3433 }
3434
3435 #[test]
3436 fn synthesized_postings_are_not_lifecycle_deferred() {
3437 let mut t = Transaction::new(date(2020, 1, 15), "synth");
3446 t = t.with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")));
3447 t = t.with_synthesized_posting(Posting::new(
3448 "Equity:Opening",
3449 Amount::new(dec!(-100), "USD"),
3450 ));
3451 let directives = vec![
3452 open_at(date(2020, 1, 1), "Equity:Opening"),
3453 Directive::Transaction(t),
3454 open_at(date(2020, 2, 1), "Assets:Bank"),
3455 ];
3456 let errors = validate(&directives);
3457 assert!(
3458 !errors
3459 .iter()
3460 .any(|e| e.code == ErrorCode::AccountNotOpen
3461 && e.message.contains("not opened until")),
3462 "synthesized postings must not arm the late lifecycle check: {errors:?}"
3463 );
3464 }
3465
3466 #[test]
3467 fn posting_on_and_after_open_date_is_clean() {
3468 let directives = vec![
3469 open_at(date(2020, 1, 1), "Assets:Bank"),
3470 open_at(date(2020, 1, 1), "Equity:Opening"),
3471 txn_at(
3472 date(2020, 1, 1),
3473 vec![
3474 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3475 Posting::new("Equity:Opening", Amount::new(dec!(-100), "USD")),
3476 ],
3477 ),
3478 ];
3479 let errors = validate(&directives);
3480 assert!(
3481 !errors
3482 .iter()
3483 .any(|e| matches!(e.code, ErrorCode::AccountNotOpen | ErrorCode::AccountClosed)),
3484 "same-date use must be clean: {errors:?}"
3485 );
3486 }
3487}