rustledger_validate/error.rs
1//! Validation error types.
2
3use rustledger_core::NaiveDate;
4use rustledger_parser::{Span, Spanned};
5use thiserror::Error;
6
7/// Validation error codes.
8///
9/// Error codes follow the spec in `spec/core/validation.md`. Every variant's
10/// [`ErrorCode::code`] is asserted to appear in that spec by
11/// `error_codes_documented_in_spec` (a drift guard).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum ErrorCode {
14 // === Account Errors (E1xxx) ===
15 /// E1001: Account used before it was opened.
16 AccountNotOpen,
17 /// E1002: Account already open (duplicate open directive).
18 AccountAlreadyOpen,
19 /// E1003: Account used after it was closed.
20 AccountClosed,
21 /// E1004: Account close with non-zero balance.
22 AccountCloseNotEmpty,
23 /// E1005: Invalid account name.
24 InvalidAccountName,
25
26 // === Balance Errors (E2xxx) ===
27 /// E2001: Balance assertion failed.
28 BalanceAssertionFailed,
29 /// E2002: Balance exceeds explicit tolerance.
30 BalanceToleranceExceeded,
31 /// E2003: Pad without subsequent balance assertion.
32 PadWithoutBalance,
33 /// E2004: Multiple pads for same balance assertion.
34 MultiplePadForBalance,
35
36 // === Transaction Errors (E3xxx) ===
37 /// E3001: Transaction does not balance.
38 TransactionUnbalanced,
39 /// E3002: Multiple postings missing amounts for same currency.
40 MultipleInterpolation,
41 /// E3003: Transaction has no postings.
42 ///
43 /// Reserved for spec parity but **never emitted**: rledger skips validation
44 /// of a posting-less transaction rather than flagging it (matching Python
45 /// beancount, which treats it as a structurally-valid no-op). See the early
46 /// return in `validate_transaction_structure` and the
47 /// `test_validate_no_postings_allowed` test.
48 NoPostings,
49 /// E3004: Transaction has single posting (warning).
50 SinglePosting,
51
52 // === Booking Errors (E4xxx) ===
53 /// E4001: No matching lot for reduction.
54 NoMatchingLot,
55 /// E4002: Insufficient units in lot for reduction.
56 InsufficientUnits,
57 /// E4003: Ambiguous lot match in STRICT mode.
58 AmbiguousLotMatch,
59 /// E4005: Cost amount is negative (cost must be non-negative).
60 NegativeCost,
61
62 // === Currency Errors (E5xxx) ===
63 /// E5001: Currency not declared (when strict mode enabled).
64 UndeclaredCurrency,
65 /// E5002: Currency not allowed in account.
66 CurrencyNotAllowed,
67 /// E5003: Invalid `precision` metadata on commodity directive (warning).
68 InvalidPrecisionMetadata,
69
70 // === Option Errors (E7xxx) ===
71 /// E7001: Unknown option name.
72 UnknownOption,
73 /// E7002: Invalid option value.
74 InvalidOptionValue,
75 /// E7003: Duplicate non-repeatable option.
76 DuplicateOption,
77
78 // === Document Errors (E8xxx) ===
79 /// E8001: Document file not found.
80 DocumentNotFound,
81
82 // === Date Errors (E10xxx) ===
83 /// E10001: Date out of order (info only).
84 DateOutOfOrder,
85 /// E10002: Entry dated in the future (warning).
86 FutureDate,
87}
88
89impl ErrorCode {
90 /// Every error-code variant. Used by the spec-drift guard test (and any
91 /// catalog enumeration). MUST list every variant — keep it in sync with the
92 /// enum; the exhaustive [`code`](Self::code) match is the compiler-enforced
93 /// source of truth for the code strings themselves.
94 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 /// Get the error code string (e.g., "E1001").
124 #[must_use]
125 pub const fn code(&self) -> &'static str {
126 match self {
127 // Account errors
128 Self::AccountNotOpen => "E1001",
129 Self::AccountAlreadyOpen => "E1002",
130 Self::AccountClosed => "E1003",
131 Self::AccountCloseNotEmpty => "E1004",
132 Self::InvalidAccountName => "E1005",
133 // Balance errors
134 Self::BalanceAssertionFailed => "E2001",
135 Self::BalanceToleranceExceeded => "E2002",
136 Self::PadWithoutBalance => "E2003",
137 Self::MultiplePadForBalance => "E2004",
138 // Transaction errors
139 Self::TransactionUnbalanced => "E3001",
140 Self::MultipleInterpolation => "E3002",
141 Self::NoPostings => "E3003",
142 Self::SinglePosting => "E3004",
143 // Booking errors
144 Self::NoMatchingLot => "E4001",
145 Self::InsufficientUnits => "E4002",
146 Self::AmbiguousLotMatch => "E4003",
147 Self::NegativeCost => "E4005",
148 // Currency errors
149 Self::UndeclaredCurrency => "E5001",
150 Self::CurrencyNotAllowed => "E5002",
151 Self::InvalidPrecisionMetadata => "E5003",
152 // Option errors
153 Self::UnknownOption => "E7001",
154 Self::InvalidOptionValue => "E7002",
155 Self::DuplicateOption => "E7003",
156 // Document errors
157 Self::DocumentNotFound => "E8001",
158 // Date errors
159 Self::DateOutOfOrder => "E10001",
160 Self::FutureDate => "E10002",
161 }
162 }
163
164 /// Check if this is a warning (not an error).
165 #[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 /// Check if this is just informational.
178 #[must_use]
179 pub const fn is_info(&self) -> bool {
180 matches!(self, Self::DateOutOfOrder)
181 }
182
183 /// Whether this diagnostic is advisory-only and must NOT be surfaced by
184 /// `check` (which mirrors `bean-check`). Python beancount does not flag
185 /// closing an account with a residual balance, so `check` stays silent; the
186 /// advisory is surfaced instead by `rledger lint closed-nonempty`.
187 #[must_use]
188 pub const fn is_advisory_only(&self) -> bool {
189 matches!(self, Self::AccountCloseNotEmpty)
190 }
191
192 /// Get the severity level.
193 #[must_use]
194 pub const fn severity(&self) -> Severity {
195 if self.is_info() {
196 Severity::Info
197 } else if self.is_warning() {
198 Severity::Warning
199 } else {
200 Severity::Error
201 }
202 }
203
204 /// Whether this error represents a parse-phase concern rather than a
205 /// semantic/validate-phase concern.
206 ///
207 /// Some checks — notably account-name structure (E1005) — are lexical in
208 /// nature and are conceptually part of parsing, even though rustledger
209 /// currently runs them during validation because the set of valid account
210 /// roots is not known until options have been resolved. Python beancount's
211 /// parser rejects these inputs at parse time, so we tag them as parse-phase
212 /// for consumers that distinguish the two (e.g. the conformance harness).
213 #[must_use]
214 pub const fn is_parse_phase(&self) -> bool {
215 matches!(self, Self::InvalidAccountName)
216 }
217}
218
219/// Whether a rendered diagnostic code string (e.g. `"E1004"`) is advisory-only.
220///
221/// The string-keyed counterpart to [`ErrorCode::is_advisory_only`], for
222/// consumers (the CLI `check`/`lint` split) that only carry the code string.
223/// Keeping it here means the set of advisory-only codes lives in one place.
224#[must_use]
225pub fn is_advisory_only_code(code: &str) -> bool {
226 code == ErrorCode::AccountCloseNotEmpty.code()
227}
228
229/// Severity level for validation messages.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
231pub enum Severity {
232 /// Ledger is invalid.
233 Error,
234 /// Suspicious but valid.
235 Warning,
236 /// Informational only.
237 Info,
238}
239
240impl std::fmt::Display for ErrorCode {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 write!(f, "{}", self.code())
243 }
244}
245
246/// A validation error.
247///
248/// The `Display` impl emits just the message text (no `[E1234]` prefix).
249/// CLI and IDE renderers are expected to prepend the error code themselves,
250/// which avoids the double-tagging seen in older output like
251/// `error[E3001]: [E3001] ...` (see issue #901).
252#[derive(Debug, Clone, Error)]
253#[error("{message}")]
254#[non_exhaustive]
255pub struct ValidationError {
256 /// Error code.
257 pub code: ErrorCode,
258 /// Error message.
259 pub message: String,
260 /// Date of the directive that caused the error.
261 pub date: NaiveDate,
262 /// Additional context.
263 pub context: Option<String>,
264 /// Advisory note attached to the error — typically used to help users
265 /// diagnose the underlying cause (e.g. "this directive was synthesized
266 /// by a plugin"). Unlike [`Self::context`], which describes data tied
267 /// to the error, the note describes something about its *origin*.
268 pub note: Option<String>,
269 /// Source span (byte offsets within the file).
270 pub span: Option<Span>,
271 /// Source file ID (index into `SourceMap`).
272 /// Uses `u16` to minimize struct size (max 65,535 files).
273 pub file_id: Option<u16>,
274}
275
276impl ValidationError {
277 /// Create a new validation error without source location.
278 #[must_use]
279 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
280 Self {
281 code,
282 message: message.into(),
283 date,
284 context: None,
285 note: None,
286 span: None,
287 file_id: None,
288 }
289 }
290
291 /// Create a new validation error with source location from a spanned directive.
292 #[must_use]
293 pub fn with_location<T>(
294 code: ErrorCode,
295 message: impl Into<String>,
296 date: NaiveDate,
297 spanned: &Spanned<T>,
298 ) -> Self {
299 Self {
300 code,
301 message: message.into(),
302 date,
303 context: None,
304 note: None,
305 span: Some(spanned.span),
306 file_id: Some(spanned.file_id),
307 }
308 }
309
310 /// Add context to this error.
311 #[must_use]
312 pub fn with_context(mut self, context: impl Into<String>) -> Self {
313 self.context = Some(context.into());
314 self
315 }
316
317 /// Attach an advisory note to this error (builder pattern).
318 #[must_use]
319 pub fn with_note(mut self, note: impl Into<String>) -> Self {
320 self.note = Some(note.into());
321 self
322 }
323
324 /// Set the source location for this error (builder pattern).
325 ///
326 /// Use this to add location info to an existing error. For creating
327 /// new errors with location, prefer [`Self::with_location`] instead.
328 #[must_use]
329 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
330 self.span = Some(spanned.span);
331 self.file_id = Some(spanned.file_id);
332 self
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339
340 #[test]
341 fn error_codes_documented_in_spec() {
342 // Drift guard: every `ErrorCode` must be documented in the validation
343 // spec. (The spec may also carry codes emitted by other crates — e.g.
344 // loader include errors E9001/E9002 — so this is a subset check, not
345 // strict equality.) Codes are backtick-wrapped in the spec (`**Code:**
346 // `E1001``), so the backtick delimiters keep `E1001` from matching
347 // inside `E10001`.
348 let spec = include_str!("../../../spec/core/validation.md");
349 let missing: Vec<&str> = ErrorCode::ALL
350 .iter()
351 .map(ErrorCode::code)
352 .filter(|code| !spec.contains(&format!("`{code}`")))
353 .collect();
354 assert!(
355 missing.is_empty(),
356 "error codes missing from spec/core/validation.md: {missing:?}"
357 );
358 }
359
360 #[test]
361 fn all_lists_distinct_codes() {
362 // Cheap completeness/dup guard for `ALL`: every code string is unique.
363 let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
364 let n = codes.len();
365 codes.sort_unstable();
366 codes.dedup();
367 assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
368 }
369
370 #[test]
371 fn invalid_account_name_is_parse_phase() {
372 // E1005 is a lexical/structural account-name check and must be
373 // reported as a parse-phase diagnostic, matching Python beancount.
374 assert!(ErrorCode::InvalidAccountName.is_parse_phase());
375 }
376
377 #[test]
378 fn other_account_errors_are_validate_phase() {
379 // Lifecycle errors remain semantic (validate-phase) concerns.
380 assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
381 assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
382 assert!(!ErrorCode::AccountClosed.is_parse_phase());
383 }
384
385 #[test]
386 fn non_account_errors_are_validate_phase() {
387 assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
388 assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
389 assert!(!ErrorCode::UnknownOption.is_parse_phase());
390 }
391}