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    /// 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}