rustledger_validate/error.rs
1//! Validation error types.
2
3use chrono::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 /// E4004: Reduction would create negative inventory.
52 NegativeInventory,
53 /// E4005: Cost amount is negative (cost must be non-negative).
54 NegativeCost,
55
56 // === Currency Errors (E5xxx) ===
57 /// E5001: Currency not declared (when strict mode enabled).
58 UndeclaredCurrency,
59 /// E5002: Currency not allowed in account.
60 CurrencyNotAllowed,
61
62 // === Metadata Errors (E6xxx) ===
63 /// E6001: Duplicate metadata key.
64 DuplicateMetadataKey,
65 /// E6002: Invalid metadata value type.
66 InvalidMetadataValue,
67
68 // === Option Errors (E7xxx) ===
69 /// E7001: Unknown option name.
70 UnknownOption,
71 /// E7002: Invalid option value.
72 InvalidOptionValue,
73 /// E7003: Duplicate non-repeatable option.
74 DuplicateOption,
75
76 // === Document Errors (E8xxx) ===
77 /// E8001: Document file not found.
78 DocumentNotFound,
79
80 // === Date Errors (E10xxx) ===
81 /// E10001: Date out of order (info only).
82 DateOutOfOrder,
83 /// E10002: Entry dated in the future (warning).
84 FutureDate,
85}
86
87impl ErrorCode {
88 /// Get the error code string (e.g., "E1001").
89 #[must_use]
90 pub const fn code(&self) -> &'static str {
91 match self {
92 // Account errors
93 Self::AccountNotOpen => "E1001",
94 Self::AccountAlreadyOpen => "E1002",
95 Self::AccountClosed => "E1003",
96 Self::AccountCloseNotEmpty => "E1004",
97 Self::InvalidAccountName => "E1005",
98 // Balance errors
99 Self::BalanceAssertionFailed => "E2001",
100 Self::BalanceToleranceExceeded => "E2002",
101 Self::PadWithoutBalance => "E2003",
102 Self::MultiplePadForBalance => "E2004",
103 // Transaction errors
104 Self::TransactionUnbalanced => "E3001",
105 Self::MultipleInterpolation => "E3002",
106 Self::NoPostings => "E3003",
107 Self::SinglePosting => "E3004",
108 // Booking errors
109 Self::NoMatchingLot => "E4001",
110 Self::InsufficientUnits => "E4002",
111 Self::AmbiguousLotMatch => "E4003",
112 Self::NegativeInventory => "E4004",
113 Self::NegativeCost => "E4005",
114 // Currency errors
115 Self::UndeclaredCurrency => "E5001",
116 Self::CurrencyNotAllowed => "E5002",
117 // Metadata errors
118 Self::DuplicateMetadataKey => "E6001",
119 Self::InvalidMetadataValue => "E6002",
120 // Option errors
121 Self::UnknownOption => "E7001",
122 Self::InvalidOptionValue => "E7002",
123 Self::DuplicateOption => "E7003",
124 // Document errors
125 Self::DocumentNotFound => "E8001",
126 // Date errors
127 Self::DateOutOfOrder => "E10001",
128 Self::FutureDate => "E10002",
129 }
130 }
131
132 /// Check if this is a warning (not an error).
133 #[must_use]
134 pub const fn is_warning(&self) -> bool {
135 matches!(
136 self,
137 Self::FutureDate
138 | Self::SinglePosting
139 | Self::AccountCloseNotEmpty
140 | Self::DateOutOfOrder
141 )
142 }
143
144 /// Check if this is just informational.
145 #[must_use]
146 pub const fn is_info(&self) -> bool {
147 matches!(self, Self::DateOutOfOrder)
148 }
149
150 /// Get the severity level.
151 #[must_use]
152 pub const fn severity(&self) -> Severity {
153 if self.is_info() {
154 Severity::Info
155 } else if self.is_warning() {
156 Severity::Warning
157 } else {
158 Severity::Error
159 }
160 }
161
162 /// Whether this error represents a parse-phase concern rather than a
163 /// semantic/validate-phase concern.
164 ///
165 /// Some checks — notably account-name structure (E1005) — are lexical in
166 /// nature and are conceptually part of parsing, even though rustledger
167 /// currently runs them during validation because the set of valid account
168 /// roots is not known until options have been resolved. Python beancount's
169 /// parser rejects these inputs at parse time, so we tag them as parse-phase
170 /// for consumers that distinguish the two (e.g. the conformance harness).
171 #[must_use]
172 pub const fn is_parse_phase(&self) -> bool {
173 matches!(self, Self::InvalidAccountName)
174 }
175}
176
177/// Severity level for validation messages.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub enum Severity {
180 /// Ledger is invalid.
181 Error,
182 /// Suspicious but valid.
183 Warning,
184 /// Informational only.
185 Info,
186}
187
188impl std::fmt::Display for ErrorCode {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 write!(f, "{}", self.code())
191 }
192}
193
194/// A validation error.
195#[derive(Debug, Clone, Error)]
196#[error("[{code}] {message}")]
197pub struct ValidationError {
198 /// Error code.
199 pub code: ErrorCode,
200 /// Error message.
201 pub message: String,
202 /// Date of the directive that caused the error.
203 pub date: NaiveDate,
204 /// Additional context.
205 pub context: Option<String>,
206 /// Source span (byte offsets within the file).
207 pub span: Option<Span>,
208 /// Source file ID (index into `SourceMap`).
209 /// Uses `u16` to minimize struct size (max 65,535 files).
210 pub file_id: Option<u16>,
211}
212
213impl ValidationError {
214 /// Create a new validation error without source location.
215 #[must_use]
216 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
217 Self {
218 code,
219 message: message.into(),
220 date,
221 context: None,
222 span: None,
223 file_id: None,
224 }
225 }
226
227 /// Create a new validation error with source location from a spanned directive.
228 #[must_use]
229 pub fn with_location<T>(
230 code: ErrorCode,
231 message: impl Into<String>,
232 date: NaiveDate,
233 spanned: &Spanned<T>,
234 ) -> Self {
235 Self {
236 code,
237 message: message.into(),
238 date,
239 context: None,
240 span: Some(spanned.span),
241 file_id: Some(spanned.file_id),
242 }
243 }
244
245 /// Add context to this error.
246 #[must_use]
247 pub fn with_context(mut self, context: impl Into<String>) -> Self {
248 self.context = Some(context.into());
249 self
250 }
251
252 /// Set the source location for this error (builder pattern).
253 ///
254 /// Use this to add location info to an existing error. For creating
255 /// new errors with location, prefer [`Self::with_location`] instead.
256 #[must_use]
257 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
258 self.span = Some(spanned.span);
259 self.file_id = Some(spanned.file_id);
260 self
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn invalid_account_name_is_parse_phase() {
270 // E1005 is a lexical/structural account-name check and must be
271 // reported as a parse-phase diagnostic, matching Python beancount.
272 assert!(ErrorCode::InvalidAccountName.is_parse_phase());
273 }
274
275 #[test]
276 fn other_account_errors_are_validate_phase() {
277 // Lifecycle errors remain semantic (validate-phase) concerns.
278 assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
279 assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
280 assert!(!ErrorCode::AccountClosed.is_parse_phase());
281 }
282
283 #[test]
284 fn non_account_errors_are_validate_phase() {
285 assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
286 assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
287 assert!(!ErrorCode::UnknownOption.is_parse_phase());
288 }
289}