Skip to main content

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