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/validation.md`.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum ErrorCode {
12 // === Account Errors (E1xxx) ===
13 /// E1001: Account used before it was opened.
14 AccountNotOpen,
15 /// E1002: Account already open (duplicate open directive).
16 AccountAlreadyOpen,
17 /// E1003: Account used after it was closed.
18 AccountClosed,
19 /// E1004: Account close with non-zero balance.
20 AccountCloseNotEmpty,
21 /// E1005: Invalid account name.
22 InvalidAccountName,
23
24 // === Balance Errors (E2xxx) ===
25 /// E2001: Balance assertion failed.
26 BalanceAssertionFailed,
27 /// E2002: Balance exceeds explicit tolerance.
28 BalanceToleranceExceeded,
29 /// E2003: Pad without subsequent balance assertion.
30 PadWithoutBalance,
31 /// E2004: Multiple pads for same balance assertion.
32 MultiplePadForBalance,
33
34 // === Transaction Errors (E3xxx) ===
35 /// E3001: Transaction does not balance.
36 TransactionUnbalanced,
37 /// E3002: Multiple postings missing amounts for same currency.
38 MultipleInterpolation,
39 /// E3003: Transaction has no postings.
40 NoPostings,
41 /// E3004: Transaction has single posting (warning).
42 SinglePosting,
43
44 // === Booking Errors (E4xxx) ===
45 /// E4001: No matching lot for reduction.
46 NoMatchingLot,
47 /// E4002: Insufficient units in lot for reduction.
48 InsufficientUnits,
49 /// E4003: Ambiguous lot match in STRICT mode.
50 AmbiguousLotMatch,
51 /// E4005: Cost amount is negative (cost must be non-negative).
52 NegativeCost,
53
54 // === Currency Errors (E5xxx) ===
55 /// E5001: Currency not declared (when strict mode enabled).
56 UndeclaredCurrency,
57 /// E5002: Currency not allowed in account.
58 CurrencyNotAllowed,
59 /// E5003: Invalid `precision` metadata on commodity directive (warning).
60 InvalidPrecisionMetadata,
61
62 // === Option Errors (E7xxx) ===
63 /// E7001: Unknown option name.
64 UnknownOption,
65 /// E7002: Invalid option value.
66 InvalidOptionValue,
67 /// E7003: Duplicate non-repeatable option.
68 DuplicateOption,
69
70 // === Document Errors (E8xxx) ===
71 /// E8001: Document file not found.
72 DocumentNotFound,
73
74 // === Date Errors (E10xxx) ===
75 /// E10001: Date out of order (info only).
76 DateOutOfOrder,
77 /// E10002: Entry dated in the future (warning).
78 FutureDate,
79}
80
81impl ErrorCode {
82 /// Get the error code string (e.g., "E1001").
83 #[must_use]
84 pub const fn code(&self) -> &'static str {
85 match self {
86 // Account errors
87 Self::AccountNotOpen => "E1001",
88 Self::AccountAlreadyOpen => "E1002",
89 Self::AccountClosed => "E1003",
90 Self::AccountCloseNotEmpty => "E1004",
91 Self::InvalidAccountName => "E1005",
92 // Balance errors
93 Self::BalanceAssertionFailed => "E2001",
94 Self::BalanceToleranceExceeded => "E2002",
95 Self::PadWithoutBalance => "E2003",
96 Self::MultiplePadForBalance => "E2004",
97 // Transaction errors
98 Self::TransactionUnbalanced => "E3001",
99 Self::MultipleInterpolation => "E3002",
100 Self::NoPostings => "E3003",
101 Self::SinglePosting => "E3004",
102 // Booking errors
103 Self::NoMatchingLot => "E4001",
104 Self::InsufficientUnits => "E4002",
105 Self::AmbiguousLotMatch => "E4003",
106 Self::NegativeCost => "E4005",
107 // Currency errors
108 Self::UndeclaredCurrency => "E5001",
109 Self::CurrencyNotAllowed => "E5002",
110 Self::InvalidPrecisionMetadata => "E5003",
111 // Option errors
112 Self::UnknownOption => "E7001",
113 Self::InvalidOptionValue => "E7002",
114 Self::DuplicateOption => "E7003",
115 // Document errors
116 Self::DocumentNotFound => "E8001",
117 // Date errors
118 Self::DateOutOfOrder => "E10001",
119 Self::FutureDate => "E10002",
120 }
121 }
122
123 /// Check if this is a warning (not an error).
124 #[must_use]
125 pub const fn is_warning(&self) -> bool {
126 matches!(
127 self,
128 Self::FutureDate
129 | Self::SinglePosting
130 | Self::AccountCloseNotEmpty
131 | Self::DateOutOfOrder
132 | Self::InvalidPrecisionMetadata
133 )
134 }
135
136 /// Check if this is just informational.
137 #[must_use]
138 pub const fn is_info(&self) -> bool {
139 matches!(self, Self::DateOutOfOrder)
140 }
141
142 /// Get the severity level.
143 #[must_use]
144 pub const fn severity(&self) -> Severity {
145 if self.is_info() {
146 Severity::Info
147 } else if self.is_warning() {
148 Severity::Warning
149 } else {
150 Severity::Error
151 }
152 }
153
154 /// Whether this error represents a parse-phase concern rather than a
155 /// semantic/validate-phase concern.
156 ///
157 /// Some checks — notably account-name structure (E1005) — are lexical in
158 /// nature and are conceptually part of parsing, even though rustledger
159 /// currently runs them during validation because the set of valid account
160 /// roots is not known until options have been resolved. Python beancount's
161 /// parser rejects these inputs at parse time, so we tag them as parse-phase
162 /// for consumers that distinguish the two (e.g. the conformance harness).
163 #[must_use]
164 pub const fn is_parse_phase(&self) -> bool {
165 matches!(self, Self::InvalidAccountName)
166 }
167}
168
169/// Severity level for validation messages.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
171pub enum Severity {
172 /// Ledger is invalid.
173 Error,
174 /// Suspicious but valid.
175 Warning,
176 /// Informational only.
177 Info,
178}
179
180impl std::fmt::Display for ErrorCode {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 write!(f, "{}", self.code())
183 }
184}
185
186/// A validation error.
187///
188/// The `Display` impl emits just the message text (no `[E1234]` prefix).
189/// CLI and IDE renderers are expected to prepend the error code themselves,
190/// which avoids the double-tagging seen in older output like
191/// `error[E3001]: [E3001] ...` (see issue #901).
192#[derive(Debug, Clone, Error)]
193#[error("{message}")]
194#[non_exhaustive]
195pub struct ValidationError {
196 /// Error code.
197 pub code: ErrorCode,
198 /// Error message.
199 pub message: String,
200 /// Date of the directive that caused the error.
201 pub date: NaiveDate,
202 /// Additional context.
203 pub context: Option<String>,
204 /// Advisory note attached to the error — typically used to help users
205 /// diagnose the underlying cause (e.g. "this directive was synthesized
206 /// by a plugin"). Unlike [`Self::context`], which describes data tied
207 /// to the error, the note describes something about its *origin*.
208 pub note: Option<String>,
209 /// Source span (byte offsets within the file).
210 pub span: Option<Span>,
211 /// Source file ID (index into `SourceMap`).
212 /// Uses `u16` to minimize struct size (max 65,535 files).
213 pub file_id: Option<u16>,
214}
215
216impl ValidationError {
217 /// Create a new validation error without source location.
218 #[must_use]
219 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
220 Self {
221 code,
222 message: message.into(),
223 date,
224 context: None,
225 note: None,
226 span: None,
227 file_id: None,
228 }
229 }
230
231 /// Create a new validation error with source location from a spanned directive.
232 #[must_use]
233 pub fn with_location<T>(
234 code: ErrorCode,
235 message: impl Into<String>,
236 date: NaiveDate,
237 spanned: &Spanned<T>,
238 ) -> Self {
239 Self {
240 code,
241 message: message.into(),
242 date,
243 context: None,
244 note: None,
245 span: Some(spanned.span),
246 file_id: Some(spanned.file_id),
247 }
248 }
249
250 /// Add context to this error.
251 #[must_use]
252 pub fn with_context(mut self, context: impl Into<String>) -> Self {
253 self.context = Some(context.into());
254 self
255 }
256
257 /// Attach an advisory note to this error (builder pattern).
258 #[must_use]
259 pub fn with_note(mut self, note: impl Into<String>) -> Self {
260 self.note = Some(note.into());
261 self
262 }
263
264 /// Set the source location for this error (builder pattern).
265 ///
266 /// Use this to add location info to an existing error. For creating
267 /// new errors with location, prefer [`Self::with_location`] instead.
268 #[must_use]
269 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
270 self.span = Some(spanned.span);
271 self.file_id = Some(spanned.file_id);
272 self
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn invalid_account_name_is_parse_phase() {
282 // E1005 is a lexical/structural account-name check and must be
283 // reported as a parse-phase diagnostic, matching Python beancount.
284 assert!(ErrorCode::InvalidAccountName.is_parse_phase());
285 }
286
287 #[test]
288 fn other_account_errors_are_validate_phase() {
289 // Lifecycle errors remain semantic (validate-phase) concerns.
290 assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
291 assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
292 assert!(!ErrorCode::AccountClosed.is_parse_phase());
293 }
294
295 #[test]
296 fn non_account_errors_are_validate_phase() {
297 assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
298 assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
299 assert!(!ErrorCode::UnknownOption.is_parse_phase());
300 }
301}