1use rustledger_core::NaiveDate;
4use rustledger_parser::{Span, Spanned};
5use thiserror::Error;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum ErrorCode {
14 AccountNotOpen,
17 AccountAlreadyOpen,
19 AccountClosed,
21 AccountCloseNotEmpty,
23 InvalidAccountName,
25
26 BalanceAssertionFailed,
29 BalanceToleranceExceeded,
31 PadWithoutBalance,
33 MultiplePadForBalance,
35
36 TransactionUnbalanced,
39 MultipleInterpolation,
41 NoPostings,
49 SinglePosting,
51
52 NoMatchingLot,
55 InsufficientUnits,
57 AmbiguousLotMatch,
59 ArithmeticOverflow,
61 NegativeCost,
63
64 UndeclaredCurrency,
67 CurrencyNotAllowed,
69 InvalidPrecisionMetadata,
71
72 MalformedBudget,
75
76 UnknownOption,
79 InvalidOptionValue,
81 DuplicateOption,
83
84 DocumentNotFound,
87
88 FutureDate,
91}
92
93impl ErrorCode {
94 #[must_use]
103 pub const fn for_booking_error(err: &rustledger_core::BookingError) -> Self {
104 use rustledger_core::BookingError as B;
105 match err {
106 B::Overflow(_) => Self::ArithmeticOverflow,
107 B::InsufficientUnits { .. } => Self::InsufficientUnits,
108 B::AmbiguousMatch { .. } => Self::AmbiguousLotMatch,
109 B::NoMatchingLot { .. } | B::CurrencyMismatch { .. } | B::MergeMismatch { .. } => {
113 Self::NoMatchingLot
114 }
115 }
116 }
117}
118
119impl ErrorCode {
120 pub const ALL: &'static [Self] = &[
125 Self::AccountNotOpen,
126 Self::AccountAlreadyOpen,
127 Self::AccountClosed,
128 Self::AccountCloseNotEmpty,
129 Self::InvalidAccountName,
130 Self::BalanceAssertionFailed,
131 Self::BalanceToleranceExceeded,
132 Self::PadWithoutBalance,
133 Self::MultiplePadForBalance,
134 Self::TransactionUnbalanced,
135 Self::MultipleInterpolation,
136 Self::NoPostings,
137 Self::SinglePosting,
138 Self::NoMatchingLot,
139 Self::InsufficientUnits,
140 Self::AmbiguousLotMatch,
141 Self::ArithmeticOverflow,
142 Self::NegativeCost,
143 Self::UndeclaredCurrency,
144 Self::CurrencyNotAllowed,
145 Self::InvalidPrecisionMetadata,
146 Self::MalformedBudget,
147 Self::UnknownOption,
148 Self::InvalidOptionValue,
149 Self::DuplicateOption,
150 Self::DocumentNotFound,
151 Self::FutureDate,
152 ];
153
154 #[must_use]
156 pub const fn code(&self) -> &'static str {
157 match self {
158 Self::AccountNotOpen => "E1001",
160 Self::AccountAlreadyOpen => "E1002",
161 Self::AccountClosed => "E1003",
162 Self::AccountCloseNotEmpty => "E1004",
163 Self::InvalidAccountName => "E1005",
164 Self::BalanceAssertionFailed => "E2001",
166 Self::BalanceToleranceExceeded => "E2002",
167 Self::PadWithoutBalance => "E2003",
168 Self::MultiplePadForBalance => "E2004",
169 Self::TransactionUnbalanced => "E3001",
171 Self::MultipleInterpolation => "E3002",
172 Self::NoPostings => "E3003",
173 Self::SinglePosting => "E3004",
174 Self::NoMatchingLot => "E4001",
176 Self::InsufficientUnits => "E4002",
177 Self::AmbiguousLotMatch => "E4003",
178 Self::ArithmeticOverflow => "E4004",
179 Self::NegativeCost => "E4005",
180 Self::UndeclaredCurrency => "E5001",
182 Self::CurrencyNotAllowed => "E5002",
183 Self::InvalidPrecisionMetadata => "E5003",
184 Self::MalformedBudget => "E11001",
190 Self::UnknownOption => "E7001",
192 Self::InvalidOptionValue => "E7002",
193 Self::DuplicateOption => "E7003",
194 Self::DocumentNotFound => "E8001",
196 Self::FutureDate => "E10002",
198 }
199 }
200
201 #[must_use]
203 pub const fn is_warning(&self) -> bool {
204 matches!(
205 self,
206 Self::FutureDate
207 | Self::SinglePosting
208 | Self::AccountCloseNotEmpty
209 | Self::InvalidPrecisionMetadata
210 | Self::MalformedBudget
211 )
212 }
213
214 #[must_use]
219 pub const fn is_advisory_only(&self) -> bool {
220 matches!(self, Self::AccountCloseNotEmpty)
221 }
222
223 #[must_use]
226 pub fn from_code(code: &str) -> Option<Self> {
227 let digits = code
228 .trim()
229 .strip_prefix(['E', 'e'])
230 .unwrap_or_else(|| code.trim());
231 let normalized = format!("E{digits}");
232 Self::ALL.iter().find(|c| c.code() == normalized).copied()
233 }
234
235 #[must_use]
237 pub const fn title(&self) -> &'static str {
238 match self {
239 Self::AccountNotOpen => "Account used before it was opened",
240 Self::AccountAlreadyOpen => "Duplicate open directive for an account",
241 Self::AccountClosed => "Account used after it was closed",
242 Self::AccountCloseNotEmpty => "Account closed with a non-zero balance",
243 Self::InvalidAccountName => "Invalid account name",
244 Self::BalanceAssertionFailed => "Balance assertion failed",
245 Self::BalanceToleranceExceeded => "Balance exceeds explicit tolerance",
246 Self::PadWithoutBalance => "Pad without a subsequent balance assertion",
247 Self::MultiplePadForBalance => "Multiple pads for the same balance assertion",
248 Self::TransactionUnbalanced => "Transaction does not balance",
249 Self::MultipleInterpolation => "Multiple postings missing amounts for one currency",
250 Self::NoPostings => "Transaction has no postings",
251 Self::SinglePosting => "Transaction has a single posting",
252 Self::NoMatchingLot => "No matching lot for reduction",
253 Self::InsufficientUnits => "Not enough units in matching lots",
254 Self::AmbiguousLotMatch => "Ambiguous lot match under STRICT booking",
255 Self::ArithmeticOverflow => "Amount exceeds the representable range",
256 Self::NegativeCost => "Negative cost",
257 Self::UndeclaredCurrency => "Currency used without a commodity declaration",
258 Self::CurrencyNotAllowed => "Currency not allowed in this account",
259 Self::InvalidPrecisionMetadata => "Invalid precision metadata on commodity",
260 Self::MalformedBudget => "Malformed budget directive",
261 Self::UnknownOption => "Unknown option name",
262 Self::InvalidOptionValue => "Invalid option value",
263 Self::DuplicateOption => "Non-repeatable option given more than once",
264 Self::DocumentNotFound => "Document file not found",
265 Self::FutureDate => "Directive dated in the future",
266 }
267 }
268
269 #[must_use]
280 pub const fn explanation(&self) -> &'static str {
281 match self {
282 Self::AccountNotOpen => {
283 "A posting or directive references an account with no prior `open` \
284 directive.\n\nEvery account must be opened on or before the date it is \
285 first used:\n\n 2024-01-01 open Assets:Bank:Checking USD\n\nFix: add \
286 an `open` directive dated on or before the first use, or correct a \
287 misspelled account name."
288 }
289 Self::AccountAlreadyOpen => {
290 "An `open` directive targets an account that is already open.\n\nThis \
291 is usually a duplicated line — often the same `open` appearing in both \
292 a main file and an `include`d file.\n\nFix: remove the duplicate \
293 `open` (keep the earliest one)."
294 }
295 Self::AccountClosed => {
296 "A posting or directive references an account after its `close` \
297 directive.\n\nFix: move the transaction before the close date, remove \
298 the `close`, or use a different account."
299 }
300 Self::AccountCloseNotEmpty => {
301 "A `close` directive targets an account that still holds a non-zero \
302 balance.\n\nAdvisory only: `check` stays silent to match `bean-check`; \
303 surface it on demand with `rledger lint closed-nonempty`.\n\nFix: zero \
304 the account (transfer the residual) before closing it."
305 }
306 Self::InvalidAccountName => {
307 "An account name does not match the required pattern.\n\nAccount names \
308 are colon-separated capitalized components rooted at one of the five \
309 account types (Assets, Liabilities, Equity, Income, Expenses — \
310 renameable via `option \"name_assets\"` etc.), e.g. \
311 `Assets:Bank:Checking`.\n\nFix: rename the account to match the \
312 pattern."
313 }
314 Self::BalanceAssertionFailed => {
315 "A `balance` assertion does not match the computed balance of the \
316 account (including its sub-accounts) at that date.\n\nThe comparison \
317 uses a tolerance inferred from the asserted amount's precision.\n\n\
318 Fix: correct the asserted amount, add the missing transactions, or \
319 insert a `pad` directive to absorb the difference. The reported \
320 difference is the exact discrepancy."
321 }
322 Self::BalanceToleranceExceeded => {
323 "A `balance` assertion with an explicit tolerance, e.g. \
324 `balance Assets:Cash 100.00 ~ 0.05 USD`, differs from the computed \
325 balance by more than that tolerance.\n\nFix: correct the amount, \
326 widen the explicit tolerance, or add the missing transactions."
327 }
328 Self::PadWithoutBalance => {
329 "A `pad` directive is never consumed by a later `balance` assertion \
330 for that account and currency.\n\nA pad means \"insert whatever \
331 amount makes the NEXT balance assertion true\" — without that \
332 balance it does nothing.\n\nFix: add the `balance` assertion after \
333 the pad, or delete the pad."
334 }
335 Self::MultiplePadForBalance => {
336 "More than one `pad` directive is pending for the same account and \
337 currency before a single `balance` assertion — it is ambiguous which \
338 pad should absorb the difference.\n\nFix: keep one pad per \
339 account/currency between consecutive balance assertions."
340 }
341 Self::TransactionUnbalanced => {
342 "The weights of a transaction's postings do not sum to zero per \
343 currency (beyond the inferred tolerance).\n\nA posting's weight is \
344 its amount, converted through its cost (`{...}`) or price \
345 (`@`/`@@`) when present.\n\nFix: correct the amounts, or leave \
346 exactly one posting's amount blank and rustledger will interpolate \
347 it. The reported residual is the exact imbalance."
348 }
349 Self::MultipleInterpolation => {
350 "More than one posting in the same currency has no amount — only one \
351 blank posting per currency can be interpolated from the others.\n\n\
352 Fix: fill in amounts so at most one posting per currency is elided."
353 }
354 Self::NoPostings => {
355 "Reserved for a transaction with zero postings.\n\nNever emitted in \
356 practice: rustledger (like Python beancount) treats a posting-less \
357 transaction as a structurally-valid no-op."
358 }
359 Self::SinglePosting => {
360 "A transaction has exactly one posting, which cannot balance on its \
361 own (warning).\n\nFix: add the offsetting posting(s), or elide the \
362 second amount to interpolate it."
363 }
364 Self::NoMatchingLot => {
365 "A cost reduction (e.g. a sale, `Assets:Stock -5 X {...}`) specifies \
366 a cost, date, or label that matches no lot held in the account's \
367 inventory.\n\nFix: check the cost spec against the actual holdings; \
368 `rledger query` with `cost_label`/`cost_date` columns shows the \
369 lots."
370 }
371 Self::InsufficientUnits => {
372 "A reduction requests more units than the matching lots hold (e.g. \
373 selling 10 when 5 are held).\n\nA failed reduction leaves the \
374 inventory untouched.\n\nFix: reduce the sold quantity, or check for \
375 a missing purchase transaction."
376 }
377 Self::AmbiguousLotMatch => {
378 "Under STRICT booking (the default), a reduction's cost spec matches \
379 more than one lot, and rustledger refuses to guess.\n\nFix: \
380 disambiguate with the lot's cost `{10.00 USD}`, date `{2024-01-02}`, \
381 or label `{\"lot-a\"}` — or open the account with a non-strict \
382 method: `2024-01-01 open Assets:Stock \"FIFO\"`."
383 }
384 Self::ArithmeticOverflow => {
385 "An amount, or a running total, is larger than rledger's decimal type \
386 can represent (about ±7.9×10²⁸ — a 96-bit type with ~28 significant \
387 digits).\n\nrledger reports this instead of rounding or clamping: a \
388 clamped figure would be printed as if it were exact, and two clamped \
389 figures of opposite sign cancel to zero, which would make an \
390 unbalanced transaction look balanced.\n\nFix: split the transaction, \
391 or use larger units (thousands, millions) for the commodity."
392 }
393 Self::NegativeCost => {
394 "A posting's cost amount is negative — a cost basis must be \
395 non-negative.\n\nFix: check the sign of the cost (the units carry \
396 the sign of a sale, not the cost)."
397 }
398 Self::UndeclaredCurrency => {
399 "A currency is used but never declared with a `commodity` directive, \
400 and commodity declarations are required (strict commodity mode).\n\n\
401 Fix: add `YYYY-MM-DD commodity CUR`, or disable the strict \
402 requirement."
403 }
404 Self::CurrencyNotAllowed => {
405 "A posting or `balance` assertion uses a currency outside the list \
406 the account was opened with (`open Assets:Cash USD` constrains the \
407 account to USD).\n\nFix: use an allowed currency, or extend the \
408 currency list on the `open` directive. An `open` with no currencies \
409 allows all."
410 }
411 Self::InvalidPrecisionMetadata => {
412 "A `commodity` directive carries a `precision:` metadata value that \
413 does not parse as a non-negative integer (warning). The declaration \
414 is ignored; display precision falls back to \
415 `option \"display_precision\"`, then to inference.\n\nFix: use e.g. \
416 `precision: 2`."
417 }
418 Self::UnknownOption => {
419 "An `option` directive names an option rustledger does not recognize \
420 (warning; the option is ignored).\n\nFix: check the option name \
421 against the options documentation — it may be misspelled or \
422 unsupported."
423 }
424 Self::InvalidOptionValue => {
425 "An `option` directive has a value that does not parse for that \
426 option's type (e.g. a non-numeric \
427 `inferred_tolerance_multiplier`).\n\nFix: correct the value per the \
428 options documentation."
429 }
430 Self::DuplicateOption => {
431 "A non-repeatable option is specified more than once (warning; the \
432 last value wins).\n\nFix: keep a single occurrence."
433 }
434 Self::DocumentNotFound => {
435 "A `document` directive references a file that does not exist. \
436 Relative paths resolve against the directory of the source file \
437 containing the directive (matching `include`).\n\nFix: correct the \
438 path, or remove the directive."
439 }
440 Self::MalformedBudget => {
441 "A `custom \"budget\"` directive that is recognizably a budget \
442 carries content rledger cannot use (warning).\n\nBudgets follow \
443 Fava's convention: `<date> custom \"budget\" <Account> \
444 \"<interval>\" <amount> <CCY>`, where interval is daily, weekly, \
445 monthly, quarterly or yearly. A trailing quoted note is fine; a \
446 trailing second figure is reported, though the budget still \
447 applies at the first.\n\nFix: correct the directive. This is not \
448 raised for a `custom \"budget\"` belonging to other tooling: a \
449 payload with neither a real interval keyword nor an \
450 account-and-amount pair is left alone everywhere, since \
451 `custom` is beancount's open extension point."
452 }
453 Self::FutureDate => {
454 "A directive is dated in the future relative to today (warning).\n\n\
455 Fix: correct the date — or ignore the warning if the future dating \
456 is intentional (e.g. scheduled entries)."
457 }
458 }
459 }
460
461 #[must_use]
463 pub const fn severity(&self) -> Severity {
464 if self.is_warning() {
468 Severity::Warning
469 } else {
470 Severity::Error
471 }
472 }
473
474 #[must_use]
484 pub const fn is_parse_phase(&self) -> bool {
485 matches!(self, Self::InvalidAccountName)
486 }
487}
488
489#[must_use]
495pub fn is_advisory_only_code(code: &str) -> bool {
496 code == ErrorCode::AccountCloseNotEmpty.code()
497}
498
499#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
501pub enum Severity {
502 Error,
504 Warning,
506 Info,
512}
513
514impl std::fmt::Display for ErrorCode {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 write!(f, "{}", self.code())
517 }
518}
519
520#[derive(Debug, Clone, Error)]
527#[error("{message}")]
528#[non_exhaustive]
529pub struct ValidationError {
530 pub code: ErrorCode,
532 pub message: String,
534 pub date: NaiveDate,
536 pub context: Option<String>,
538 pub note: Option<String>,
543 pub span: Option<Span>,
545 pub file_id: Option<u16>,
548}
549
550impl ValidationError {
551 #[must_use]
553 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
554 Self {
555 code,
556 message: message.into(),
557 date,
558 context: None,
559 note: None,
560 span: None,
561 file_id: None,
562 }
563 }
564
565 #[must_use]
567 pub fn with_location<T>(
568 code: ErrorCode,
569 message: impl Into<String>,
570 date: NaiveDate,
571 spanned: &Spanned<T>,
572 ) -> Self {
573 Self {
574 code,
575 message: message.into(),
576 date,
577 context: None,
578 note: None,
579 span: Some(spanned.span),
580 file_id: Some(spanned.file_id),
581 }
582 }
583
584 #[must_use]
586 pub fn with_context(mut self, context: impl Into<String>) -> Self {
587 self.context = Some(context.into());
588 self
589 }
590
591 #[must_use]
593 pub fn with_note(mut self, note: impl Into<String>) -> Self {
594 self.note = Some(note.into());
595 self
596 }
597
598 #[must_use]
603 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
604 self.span = Some(spanned.span);
605 self.file_id = Some(spanned.file_id);
606 self
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn error_codes_documented_in_spec() {
616 let spec_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/core/validation.md");
630 let Ok(spec) = std::fs::read_to_string(spec_path) else {
631 eprintln!(
632 "skipping error_codes_documented_in_spec: {spec_path} not present (published-crate build)"
633 );
634 return;
635 };
636 let missing: Vec<&str> = ErrorCode::ALL
637 .iter()
638 .map(ErrorCode::code)
639 .filter(|code| !spec.contains(&format!("`{code}`")))
640 .collect();
641 assert!(
642 missing.is_empty(),
643 "error codes missing from spec/core/validation.md: {missing:?}"
644 );
645 }
646
647 #[test]
648 fn all_lists_distinct_codes() {
649 let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
651 let n = codes.len();
652 codes.sort_unstable();
653 codes.dedup();
654 assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
655 }
656
657 #[test]
658 fn invalid_account_name_is_parse_phase() {
659 assert!(ErrorCode::InvalidAccountName.is_parse_phase());
662 }
663
664 #[test]
665 fn other_account_errors_are_validate_phase() {
666 assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
668 assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
669 assert!(!ErrorCode::AccountClosed.is_parse_phase());
670 }
671
672 #[test]
673 fn non_account_errors_are_validate_phase() {
674 assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
675 assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
676 assert!(!ErrorCode::UnknownOption.is_parse_phase());
677 }
678}