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
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
60 // === Metadata Errors (E6xxx) ===
61 /// E6001: Duplicate metadata key.
62 DuplicateMetadataKey,
63 /// E6002: Invalid metadata value type.
64 InvalidMetadataValue,
65
66 // === Option Errors (E7xxx) ===
67 /// E7001: Unknown option name.
68 UnknownOption,
69 /// E7002: Invalid option value.
70 InvalidOptionValue,
71 /// E7003: Duplicate non-repeatable option.
72 DuplicateOption,
73
74 // === Document Errors (E8xxx) ===
75 /// E8001: Document file not found.
76 DocumentNotFound,
77
78 // === Date Errors (E10xxx) ===
79 /// E10001: Date out of order (info only).
80 DateOutOfOrder,
81 /// E10002: Entry dated in the future (warning).
82 FutureDate,
83}
84
85impl ErrorCode {
86 /// Get the error code string (e.g., "E1001").
87 #[must_use]
88 pub const fn code(&self) -> &'static str {
89 match self {
90 // Account errors
91 Self::AccountNotOpen => "E1001",
92 Self::AccountAlreadyOpen => "E1002",
93 Self::AccountClosed => "E1003",
94 Self::AccountCloseNotEmpty => "E1004",
95 Self::InvalidAccountName => "E1005",
96 // Balance errors
97 Self::BalanceAssertionFailed => "E2001",
98 Self::BalanceToleranceExceeded => "E2002",
99 Self::PadWithoutBalance => "E2003",
100 Self::MultiplePadForBalance => "E2004",
101 // Transaction errors
102 Self::TransactionUnbalanced => "E3001",
103 Self::MultipleInterpolation => "E3002",
104 Self::NoPostings => "E3003",
105 Self::SinglePosting => "E3004",
106 // Booking errors
107 Self::NoMatchingLot => "E4001",
108 Self::InsufficientUnits => "E4002",
109 Self::AmbiguousLotMatch => "E4003",
110 Self::NegativeInventory => "E4004",
111 // Currency errors
112 Self::UndeclaredCurrency => "E5001",
113 Self::CurrencyNotAllowed => "E5002",
114 // Metadata errors
115 Self::DuplicateMetadataKey => "E6001",
116 Self::InvalidMetadataValue => "E6002",
117 // Option errors
118 Self::UnknownOption => "E7001",
119 Self::InvalidOptionValue => "E7002",
120 Self::DuplicateOption => "E7003",
121 // Document errors
122 Self::DocumentNotFound => "E8001",
123 // Date errors
124 Self::DateOutOfOrder => "E10001",
125 Self::FutureDate => "E10002",
126 }
127 }
128
129 /// Check if this is a warning (not an error).
130 #[must_use]
131 pub const fn is_warning(&self) -> bool {
132 matches!(
133 self,
134 Self::FutureDate
135 | Self::SinglePosting
136 | Self::AccountCloseNotEmpty
137 | Self::DateOutOfOrder
138 )
139 }
140
141 /// Check if this is just informational.
142 #[must_use]
143 pub const fn is_info(&self) -> bool {
144 matches!(self, Self::DateOutOfOrder)
145 }
146
147 /// Get the severity level.
148 #[must_use]
149 pub const fn severity(&self) -> Severity {
150 if self.is_info() {
151 Severity::Info
152 } else if self.is_warning() {
153 Severity::Warning
154 } else {
155 Severity::Error
156 }
157 }
158}
159
160/// Severity level for validation messages.
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
162pub enum Severity {
163 /// Ledger is invalid.
164 Error,
165 /// Suspicious but valid.
166 Warning,
167 /// Informational only.
168 Info,
169}
170
171impl std::fmt::Display for ErrorCode {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 write!(f, "{}", self.code())
174 }
175}
176
177/// A validation error.
178#[derive(Debug, Clone, Error)]
179#[error("[{code}] {message}")]
180pub struct ValidationError {
181 /// Error code.
182 pub code: ErrorCode,
183 /// Error message.
184 pub message: String,
185 /// Date of the directive that caused the error.
186 pub date: NaiveDate,
187 /// Additional context.
188 pub context: Option<String>,
189 /// Source span (byte offsets within the file).
190 pub span: Option<Span>,
191 /// Source file ID (index into `SourceMap`).
192 /// Uses `u16` to minimize struct size (max 65,535 files).
193 pub file_id: Option<u16>,
194}
195
196impl ValidationError {
197 /// Create a new validation error without source location.
198 #[must_use]
199 pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
200 Self {
201 code,
202 message: message.into(),
203 date,
204 context: None,
205 span: None,
206 file_id: None,
207 }
208 }
209
210 /// Create a new validation error with source location from a spanned directive.
211 #[must_use]
212 pub fn with_location<T>(
213 code: ErrorCode,
214 message: impl Into<String>,
215 date: NaiveDate,
216 spanned: &Spanned<T>,
217 ) -> Self {
218 Self {
219 code,
220 message: message.into(),
221 date,
222 context: None,
223 span: Some(spanned.span),
224 file_id: Some(spanned.file_id),
225 }
226 }
227
228 /// Add context to this error.
229 #[must_use]
230 pub fn with_context(mut self, context: impl Into<String>) -> Self {
231 self.context = Some(context.into());
232 self
233 }
234
235 /// Set the source location for this error (builder pattern).
236 ///
237 /// Use this to add location info to an existing error. For creating
238 /// new errors with location, prefer [`Self::with_location`] instead.
239 #[must_use]
240 pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
241 self.span = Some(spanned.span);
242 self.file_id = Some(spanned.file_id);
243 self
244 }
245}