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 NegativeCost,
61
62 UndeclaredCurrency,
65 CurrencyNotAllowed,
67 InvalidPrecisionMetadata,
69
70 UnknownOption,
73 InvalidOptionValue,
75 DuplicateOption,
77
78 DocumentNotFound,
81
82 DateOutOfOrder,
85 FutureDate,
87}
88
89impl ErrorCode {
90 pub const ALL: &'static [Self] = &[
95 Self::AccountNotOpen,
96 Self::AccountAlreadyOpen,
97 Self::AccountClosed,
98 Self::AccountCloseNotEmpty,
99 Self::InvalidAccountName,
100 Self::BalanceAssertionFailed,
101 Self::BalanceToleranceExceeded,
102 Self::PadWithoutBalance,
103 Self::MultiplePadForBalance,
104 Self::TransactionUnbalanced,
105 Self::MultipleInterpolation,
106 Self::NoPostings,
107 Self::SinglePosting,
108 Self::NoMatchingLot,
109 Self::InsufficientUnits,
110 Self::AmbiguousLotMatch,
111 Self::NegativeCost,
112 Self::UndeclaredCurrency,
113 Self::CurrencyNotAllowed,
114 Self::InvalidPrecisionMetadata,
115 Self::UnknownOption,
116 Self::InvalidOptionValue,
117 Self::DuplicateOption,
118 Self::DocumentNotFound,
119 Self::DateOutOfOrder,
120 Self::FutureDate,
121 ];
122
123 #[must_use]
125 pub const fn code(&self) -> &'static str {
126 match self {
127 Self::AccountNotOpen => "E1001",
129 Self::AccountAlreadyOpen => "E1002",
130 Self::AccountClosed => "E1003",
131 Self::AccountCloseNotEmpty => "E1004",
132 Self::InvalidAccountName => "E1005",
133 Self::BalanceAssertionFailed => "E2001",
135 Self::BalanceToleranceExceeded => "E2002",
136 Self::PadWithoutBalance => "E2003",
137 Self::MultiplePadForBalance => "E2004",
138 Self::TransactionUnbalanced => "E3001",
140 Self::MultipleInterpolation => "E3002",
141 Self::NoPostings => "E3003",
142 Self::SinglePosting => "E3004",
143 Self::NoMatchingLot => "E4001",
145 Self::InsufficientUnits => "E4002",
146 Self::AmbiguousLotMatch => "E4003",
147 Self::NegativeCost => "E4005",
148 Self::UndeclaredCurrency => "E5001",
150 Self::CurrencyNotAllowed => "E5002",
151 Self::InvalidPrecisionMetadata => "E5003",
152 Self::UnknownOption => "E7001",
154 Self::InvalidOptionValue => "E7002",
155 Self::DuplicateOption => "E7003",
156 Self::DocumentNotFound => "E8001",
158 Self::DateOutOfOrder => "E10001",
160 Self::FutureDate => "E10002",
161 }
162 }
163
164 #[must_use]
166 pub const fn is_warning(&self) -> bool {
167 matches!(
168 self,
169 Self::FutureDate
170 | Self::SinglePosting
171 | Self::AccountCloseNotEmpty
172 | Self::DateOutOfOrder
173 | Self::InvalidPrecisionMetadata
174 )
175 }
176
177 #[must_use]
179 pub const fn is_info(&self) -> bool {
180 matches!(self, Self::DateOutOfOrder)
181 }
182
183 #[must_use]
188 pub const fn is_advisory_only(&self) -> bool {
189 matches!(self, Self::AccountCloseNotEmpty)
190 }
191
192 #[must_use]
195 pub fn from_code(code: &str) -> Option<Self> {
196 let digits = code
197 .trim()
198 .strip_prefix(['E', 'e'])
199 .unwrap_or_else(|| code.trim());
200 let normalized = format!("E{digits}");
201 Self::ALL.iter().find(|c| c.code() == normalized).copied()
202 }
203
204 #[must_use]
206 pub const fn title(&self) -> &'static str {
207 match self {
208 Self::AccountNotOpen => "Account used before it was opened",
209 Self::AccountAlreadyOpen => "Duplicate open directive for an account",
210 Self::AccountClosed => "Account used after it was closed",
211 Self::AccountCloseNotEmpty => "Account closed with a non-zero balance",
212 Self::InvalidAccountName => "Invalid account name",
213 Self::BalanceAssertionFailed => "Balance assertion failed",
214 Self::BalanceToleranceExceeded => "Balance exceeds explicit tolerance",
215 Self::PadWithoutBalance => "Pad without a subsequent balance assertion",
216 Self::MultiplePadForBalance => "Multiple pads for the same balance assertion",
217 Self::TransactionUnbalanced => "Transaction does not balance",
218 Self::MultipleInterpolation => "Multiple postings missing amounts for one currency",
219 Self::NoPostings => "Transaction has no postings",
220 Self::SinglePosting => "Transaction has a single posting",
221 Self::NoMatchingLot => "No matching lot for reduction",
222 Self::InsufficientUnits => "Not enough units in matching lots",
223 Self::AmbiguousLotMatch => "Ambiguous lot match under STRICT booking",
224 Self::NegativeCost => "Negative cost",
225 Self::UndeclaredCurrency => "Currency used without a commodity declaration",
226 Self::CurrencyNotAllowed => "Currency not allowed in this account",
227 Self::InvalidPrecisionMetadata => "Invalid precision metadata on commodity",
228 Self::UnknownOption => "Unknown option name",
229 Self::InvalidOptionValue => "Invalid option value",
230 Self::DuplicateOption => "Non-repeatable option given more than once",
231 Self::DocumentNotFound => "Document file not found",
232 Self::DateOutOfOrder => "Directive date out of order",
233 Self::FutureDate => "Directive dated in the future",
234 }
235 }
236
237 #[must_use]
248 pub const fn explanation(&self) -> &'static str {
249 match self {
250 Self::AccountNotOpen => {
251 "A posting or directive references an account with no prior `open` \
252 directive.\n\nEvery account must be opened on or before the date it is \
253 first used:\n\n 2024-01-01 open Assets:Bank:Checking USD\n\nFix: add \
254 an `open` directive dated on or before the first use, or correct a \
255 misspelled account name."
256 }
257 Self::AccountAlreadyOpen => {
258 "An `open` directive targets an account that is already open.\n\nThis \
259 is usually a duplicated line — often the same `open` appearing in both \
260 a main file and an `include`d file.\n\nFix: remove the duplicate \
261 `open` (keep the earliest one)."
262 }
263 Self::AccountClosed => {
264 "A posting or directive references an account after its `close` \
265 directive.\n\nFix: move the transaction before the close date, remove \
266 the `close`, or use a different account."
267 }
268 Self::AccountCloseNotEmpty => {
269 "A `close` directive targets an account that still holds a non-zero \
270 balance.\n\nAdvisory only: `check` stays silent to match `bean-check`; \
271 surface it on demand with `rledger lint closed-nonempty`.\n\nFix: zero \
272 the account (transfer the residual) before closing it."
273 }
274 Self::InvalidAccountName => {
275 "An account name does not match the required pattern.\n\nAccount names \
276 are colon-separated capitalized components rooted at one of the five \
277 account types (Assets, Liabilities, Equity, Income, Expenses — \
278 renameable via `option \"name_assets\"` etc.), e.g. \
279 `Assets:Bank:Checking`.\n\nFix: rename the account to match the \
280 pattern."
281 }
282 Self::BalanceAssertionFailed => {
283 "A `balance` assertion does not match the computed balance of the \
284 account (including its sub-accounts) at that date.\n\nThe comparison \
285 uses a tolerance inferred from the asserted amount's precision.\n\n\
286 Fix: correct the asserted amount, add the missing transactions, or \
287 insert a `pad` directive to absorb the difference. The reported \
288 difference is the exact discrepancy."
289 }
290 Self::BalanceToleranceExceeded => {
291 "A `balance` assertion with an explicit tolerance, e.g. \
292 `balance Assets:Cash 100.00 ~ 0.05 USD`, differs from the computed \
293 balance by more than that tolerance.\n\nFix: correct the amount, \
294 widen the explicit tolerance, or add the missing transactions."
295 }
296 Self::PadWithoutBalance => {
297 "A `pad` directive is never consumed by a later `balance` assertion \
298 for that account and currency.\n\nA pad means \"insert whatever \
299 amount makes the NEXT balance assertion true\" — without that \
300 balance it does nothing.\n\nFix: add the `balance` assertion after \
301 the pad, or delete the pad."
302 }
303 Self::MultiplePadForBalance => {
304 "More than one `pad` directive is pending for the same account and \
305 currency before a single `balance` assertion — it is ambiguous which \
306 pad should absorb the difference.\n\nFix: keep one pad per \
307 account/currency between consecutive balance assertions."
308 }
309 Self::TransactionUnbalanced => {
310 "The weights of a transaction's postings do not sum to zero per \
311 currency (beyond the inferred tolerance).\n\nA posting's weight is \
312 its amount, converted through its cost (`{...}`) or price \
313 (`@`/`@@`) when present.\n\nFix: correct the amounts, or leave \
314 exactly one posting's amount blank and rustledger will interpolate \
315 it. The reported residual is the exact imbalance."
316 }
317 Self::MultipleInterpolation => {
318 "More than one posting in the same currency has no amount — only one \
319 blank posting per currency can be interpolated from the others.\n\n\
320 Fix: fill in amounts so at most one posting per currency is elided."
321 }
322 Self::NoPostings => {
323 "Reserved for a transaction with zero postings.\n\nNever emitted in \
324 practice: rustledger (like Python beancount) treats a posting-less \
325 transaction as a structurally-valid no-op."
326 }
327 Self::SinglePosting => {
328 "A transaction has exactly one posting, which cannot balance on its \
329 own (warning).\n\nFix: add the offsetting posting(s), or elide the \
330 second amount to interpolate it."
331 }
332 Self::NoMatchingLot => {
333 "A cost reduction (e.g. a sale, `Assets:Stock -5 X {...}`) specifies \
334 a cost, date, or label that matches no lot held in the account's \
335 inventory.\n\nFix: check the cost spec against the actual holdings; \
336 `rledger query` with `cost_label`/`cost_date` columns shows the \
337 lots."
338 }
339 Self::InsufficientUnits => {
340 "A reduction requests more units than the matching lots hold (e.g. \
341 selling 10 when 5 are held).\n\nA failed reduction leaves the \
342 inventory untouched.\n\nFix: reduce the sold quantity, or check for \
343 a missing purchase transaction."
344 }
345 Self::AmbiguousLotMatch => {
346 "Under STRICT booking (the default), a reduction's cost spec matches \
347 more than one lot, and rustledger refuses to guess.\n\nFix: \
348 disambiguate with the lot's cost `{10.00 USD}`, date `{2024-01-02}`, \
349 or label `{\"lot-a\"}` — or open the account with a non-strict \
350 method: `2024-01-01 open Assets:Stock \"FIFO\"`."
351 }
352 Self::NegativeCost => {
353 "A posting's cost amount is negative — a cost basis must be \
354 non-negative.\n\nFix: check the sign of the cost (the units carry \
355 the sign of a sale, not the cost)."
356 }
357 Self::UndeclaredCurrency => {
358 "A currency is used but never declared with a `commodity` directive, \
359 and commodity declarations are required (strict commodity mode).\n\n\
360 Fix: add `YYYY-MM-DD commodity CUR`, or disable the strict \
361 requirement."
362 }
363 Self::CurrencyNotAllowed => {
364 "A posting or `balance` assertion uses a currency outside the list \
365 the account was opened with (`open Assets:Cash USD` constrains the \
366 account to USD).\n\nFix: use an allowed currency, or extend the \
367 currency list on the `open` directive. An `open` with no currencies \
368 allows all."
369 }
370 Self::InvalidPrecisionMetadata => {
371 "A `commodity` directive carries a `precision:` metadata value that \
372 does not parse as a non-negative integer (warning). The declaration \
373 is ignored; display precision falls back to \
374 `option \"display_precision\"`, then to inference.\n\nFix: use e.g. \
375 `precision: 2`."
376 }
377 Self::UnknownOption => {
378 "An `option` directive names an option rustledger does not recognize \
379 (warning; the option is ignored).\n\nFix: check the option name \
380 against the options documentation — it may be misspelled or \
381 unsupported."
382 }
383 Self::InvalidOptionValue => {
384 "An `option` directive has a value that does not parse for that \
385 option's type (e.g. a non-numeric \
386 `inferred_tolerance_multiplier`).\n\nFix: correct the value per the \
387 options documentation."
388 }
389 Self::DuplicateOption => {
390 "A non-repeatable option is specified more than once (warning; the \
391 last value wins).\n\nFix: keep a single occurrence."
392 }
393 Self::DocumentNotFound => {
394 "A `document` directive references a file that does not exist. \
395 Relative paths resolve against the directory of the source file \
396 containing the directive (matching `include`).\n\nFix: correct the \
397 path, or remove the directive."
398 }
399 Self::DateOutOfOrder => {
400 "A directive's date is earlier than the preceding directive's date \
401 in the same file (informational only — directives are sorted before \
402 processing, so this never changes results).\n\nFix: reorder the \
403 file chronologically if you care about source order."
404 }
405 Self::FutureDate => {
406 "A directive is dated in the future relative to today (warning).\n\n\
407 Fix: correct the date — or ignore the warning if the future dating \
408 is intentional (e.g. scheduled entries)."
409 }
410 }
411 }
412
413 #[must_use]
415 pub const fn severity(&self) -> Severity {
416 if self.is_info() {
417 Severity::Info
418 } else if self.is_warning() {
419 Severity::Warning
420 } else {
421 Severity::Error
422 }
423 }
424
425 #[must_use]
435 pub const fn is_parse_phase(&self) -> bool {
436 matches!(self, Self::InvalidAccountName)
437 }
438}
439
440#[must_use]
446pub fn is_advisory_only_code(code: &str) -> bool {
447 code == ErrorCode::AccountCloseNotEmpty.code()
448}
449
450#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
452pub enum Severity {
453 Error,
455 Warning,
457 Info,
459}
460
461impl std::fmt::Display for ErrorCode {
462 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463 write!(f, "{}", self.code())
464 }
465}
466
467#[derive(Debug, Clone, Error)]
474#[error("{message}")]
475#[non_exhaustive]
476pub struct ValidationError {
477 pub code: ErrorCode,
479 pub message: String,
481 pub date: NaiveDate,
483 pub context: Option<String>,
485 pub note: Option<String>,
490 pub span: Option<Span>,
492 pub file_id: Option<u16>,
495}
496
497impl ValidationError {
498 #[must_use]
500 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
501 Self {
502 code,
503 message: message.into(),
504 date,
505 context: None,
506 note: None,
507 span: None,
508 file_id: None,
509 }
510 }
511
512 #[must_use]
514 pub fn with_location<T>(
515 code: ErrorCode,
516 message: impl Into<String>,
517 date: NaiveDate,
518 spanned: &Spanned<T>,
519 ) -> Self {
520 Self {
521 code,
522 message: message.into(),
523 date,
524 context: None,
525 note: None,
526 span: Some(spanned.span),
527 file_id: Some(spanned.file_id),
528 }
529 }
530
531 #[must_use]
533 pub fn with_context(mut self, context: impl Into<String>) -> Self {
534 self.context = Some(context.into());
535 self
536 }
537
538 #[must_use]
540 pub fn with_note(mut self, note: impl Into<String>) -> Self {
541 self.note = Some(note.into());
542 self
543 }
544
545 #[must_use]
550 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
551 self.span = Some(spanned.span);
552 self.file_id = Some(spanned.file_id);
553 self
554 }
555}
556
557#[cfg(test)]
558mod tests {
559 use super::*;
560
561 #[test]
562 fn error_codes_documented_in_spec() {
563 let spec_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/core/validation.md");
577 let Ok(spec) = std::fs::read_to_string(spec_path) else {
578 eprintln!(
579 "skipping error_codes_documented_in_spec: {spec_path} not present (published-crate build)"
580 );
581 return;
582 };
583 let missing: Vec<&str> = ErrorCode::ALL
584 .iter()
585 .map(ErrorCode::code)
586 .filter(|code| !spec.contains(&format!("`{code}`")))
587 .collect();
588 assert!(
589 missing.is_empty(),
590 "error codes missing from spec/core/validation.md: {missing:?}"
591 );
592 }
593
594 #[test]
595 fn all_lists_distinct_codes() {
596 let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
598 let n = codes.len();
599 codes.sort_unstable();
600 codes.dedup();
601 assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
602 }
603
604 #[test]
605 fn invalid_account_name_is_parse_phase() {
606 assert!(ErrorCode::InvalidAccountName.is_parse_phase());
609 }
610
611 #[test]
612 fn other_account_errors_are_validate_phase() {
613 assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
615 assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
616 assert!(!ErrorCode::AccountClosed.is_parse_phase());
617 }
618
619 #[test]
620 fn non_account_errors_are_validate_phase() {
621 assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
622 assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
623 assert!(!ErrorCode::UnknownOption.is_parse_phase());
624 }
625}