Skip to main content

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}