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/core/validation.md`. Every variant's
10/// [`ErrorCode::code`] is asserted to appear in that spec by
11/// `error_codes_documented_in_spec` (a drift guard).
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum ErrorCode {
14    // === Account Errors (E1xxx) ===
15    /// E1001: Account used before it was opened.
16    AccountNotOpen,
17    /// E1002: Account already open (duplicate open directive).
18    AccountAlreadyOpen,
19    /// E1003: Account used after it was closed.
20    AccountClosed,
21    /// E1004: Account close with non-zero balance.
22    AccountCloseNotEmpty,
23    /// E1005: Invalid account name.
24    InvalidAccountName,
25
26    // === Balance Errors (E2xxx) ===
27    /// E2001: Balance assertion failed.
28    BalanceAssertionFailed,
29    /// E2002: Balance exceeds explicit tolerance.
30    BalanceToleranceExceeded,
31    /// E2003: Pad without subsequent balance assertion.
32    PadWithoutBalance,
33    /// E2004: Multiple pads for same balance assertion.
34    MultiplePadForBalance,
35
36    // === Transaction Errors (E3xxx) ===
37    /// E3001: Transaction does not balance.
38    TransactionUnbalanced,
39    /// E3002: Multiple postings missing amounts for same currency.
40    MultipleInterpolation,
41    /// E3003: Transaction has no postings.
42    ///
43    /// Reserved for spec parity but **never emitted**: rledger skips validation
44    /// of a posting-less transaction rather than flagging it (matching Python
45    /// beancount, which treats it as a structurally-valid no-op). See the early
46    /// return in `validate_transaction_structure` and the
47    /// `test_validate_no_postings_allowed` test.
48    NoPostings,
49    /// E3004: Transaction has single posting (warning).
50    SinglePosting,
51
52    // === Booking Errors (E4xxx) ===
53    /// E4001: No matching lot for reduction.
54    NoMatchingLot,
55    /// E4002: Insufficient units in lot for reduction.
56    InsufficientUnits,
57    /// E4003: Ambiguous lot match in STRICT mode.
58    AmbiguousLotMatch,
59    /// E4005: Cost amount is negative (cost must be non-negative).
60    NegativeCost,
61
62    // === Currency Errors (E5xxx) ===
63    /// E5001: Currency not declared (when strict mode enabled).
64    UndeclaredCurrency,
65    /// E5002: Currency not allowed in account.
66    CurrencyNotAllowed,
67    /// E5003: Invalid `precision` metadata on commodity directive (warning).
68    InvalidPrecisionMetadata,
69
70    // === Option Errors (E7xxx) ===
71    /// E7001: Unknown option name.
72    UnknownOption,
73    /// E7002: Invalid option value.
74    InvalidOptionValue,
75    /// E7003: Duplicate non-repeatable option.
76    DuplicateOption,
77
78    // === Document Errors (E8xxx) ===
79    /// E8001: Document file not found.
80    DocumentNotFound,
81
82    // === Date Errors (E10xxx) ===
83    /// E10001: Date out of order (info only).
84    DateOutOfOrder,
85    /// E10002: Entry dated in the future (warning).
86    FutureDate,
87}
88
89impl ErrorCode {
90    /// Every error-code variant. Used by the spec-drift guard test (and any
91    /// catalog enumeration). MUST list every variant — keep it in sync with the
92    /// enum; the exhaustive [`code`](Self::code) match is the compiler-enforced
93    /// source of truth for the code strings themselves.
94    pub const ALL: &'static [Self] = &[
95        Self::AccountNotOpen,
96        Self::AccountAlreadyOpen,
97        Self::AccountClosed,
98        Self::AccountCloseNotEmpty,
99        Self::InvalidAccountName,
100        Self::BalanceAssertionFailed,
101        Self::BalanceToleranceExceeded,
102        Self::PadWithoutBalance,
103        Self::MultiplePadForBalance,
104        Self::TransactionUnbalanced,
105        Self::MultipleInterpolation,
106        Self::NoPostings,
107        Self::SinglePosting,
108        Self::NoMatchingLot,
109        Self::InsufficientUnits,
110        Self::AmbiguousLotMatch,
111        Self::NegativeCost,
112        Self::UndeclaredCurrency,
113        Self::CurrencyNotAllowed,
114        Self::InvalidPrecisionMetadata,
115        Self::UnknownOption,
116        Self::InvalidOptionValue,
117        Self::DuplicateOption,
118        Self::DocumentNotFound,
119        Self::DateOutOfOrder,
120        Self::FutureDate,
121    ];
122
123    /// Get the error code string (e.g., "E1001").
124    #[must_use]
125    pub const fn code(&self) -> &'static str {
126        match self {
127            // Account errors
128            Self::AccountNotOpen => "E1001",
129            Self::AccountAlreadyOpen => "E1002",
130            Self::AccountClosed => "E1003",
131            Self::AccountCloseNotEmpty => "E1004",
132            Self::InvalidAccountName => "E1005",
133            // Balance errors
134            Self::BalanceAssertionFailed => "E2001",
135            Self::BalanceToleranceExceeded => "E2002",
136            Self::PadWithoutBalance => "E2003",
137            Self::MultiplePadForBalance => "E2004",
138            // Transaction errors
139            Self::TransactionUnbalanced => "E3001",
140            Self::MultipleInterpolation => "E3002",
141            Self::NoPostings => "E3003",
142            Self::SinglePosting => "E3004",
143            // Booking errors
144            Self::NoMatchingLot => "E4001",
145            Self::InsufficientUnits => "E4002",
146            Self::AmbiguousLotMatch => "E4003",
147            Self::NegativeCost => "E4005",
148            // Currency errors
149            Self::UndeclaredCurrency => "E5001",
150            Self::CurrencyNotAllowed => "E5002",
151            Self::InvalidPrecisionMetadata => "E5003",
152            // Option errors
153            Self::UnknownOption => "E7001",
154            Self::InvalidOptionValue => "E7002",
155            Self::DuplicateOption => "E7003",
156            // Document errors
157            Self::DocumentNotFound => "E8001",
158            // Date errors
159            Self::DateOutOfOrder => "E10001",
160            Self::FutureDate => "E10002",
161        }
162    }
163
164    /// Check if this is a warning (not an error).
165    #[must_use]
166    pub const fn is_warning(&self) -> bool {
167        matches!(
168            self,
169            Self::FutureDate
170                | Self::SinglePosting
171                | Self::AccountCloseNotEmpty
172                | Self::DateOutOfOrder
173                | Self::InvalidPrecisionMetadata
174        )
175    }
176
177    /// Check if this is just informational.
178    #[must_use]
179    pub const fn is_info(&self) -> bool {
180        matches!(self, Self::DateOutOfOrder)
181    }
182
183    /// Whether this diagnostic is advisory-only and must NOT be surfaced by
184    /// `check` (which mirrors `bean-check`). Python beancount does not flag
185    /// closing an account with a residual balance, so `check` stays silent; the
186    /// advisory is surfaced instead by `rledger lint closed-nonempty`.
187    #[must_use]
188    pub const fn is_advisory_only(&self) -> bool {
189        matches!(self, Self::AccountCloseNotEmpty)
190    }
191
192    /// Get the severity level.
193    #[must_use]
194    pub const fn severity(&self) -> Severity {
195        if self.is_info() {
196            Severity::Info
197        } else if self.is_warning() {
198            Severity::Warning
199        } else {
200            Severity::Error
201        }
202    }
203
204    /// Whether this error represents a parse-phase concern rather than a
205    /// semantic/validate-phase concern.
206    ///
207    /// Some checks — notably account-name structure (E1005) — are lexical in
208    /// nature and are conceptually part of parsing, even though rustledger
209    /// currently runs them during validation because the set of valid account
210    /// roots is not known until options have been resolved. Python beancount's
211    /// parser rejects these inputs at parse time, so we tag them as parse-phase
212    /// for consumers that distinguish the two (e.g. the conformance harness).
213    #[must_use]
214    pub const fn is_parse_phase(&self) -> bool {
215        matches!(self, Self::InvalidAccountName)
216    }
217}
218
219/// Whether a rendered diagnostic code string (e.g. `"E1004"`) is advisory-only.
220///
221/// The string-keyed counterpart to [`ErrorCode::is_advisory_only`], for
222/// consumers (the CLI `check`/`lint` split) that only carry the code string.
223/// Keeping it here means the set of advisory-only codes lives in one place.
224#[must_use]
225pub fn is_advisory_only_code(code: &str) -> bool {
226    code == ErrorCode::AccountCloseNotEmpty.code()
227}
228
229/// Severity level for validation messages.
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
231pub enum Severity {
232    /// Ledger is invalid.
233    Error,
234    /// Suspicious but valid.
235    Warning,
236    /// Informational only.
237    Info,
238}
239
240impl std::fmt::Display for ErrorCode {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        write!(f, "{}", self.code())
243    }
244}
245
246/// A validation error.
247///
248/// The `Display` impl emits just the message text (no `[E1234]` prefix).
249/// CLI and IDE renderers are expected to prepend the error code themselves,
250/// which avoids the double-tagging seen in older output like
251/// `error[E3001]: [E3001] ...` (see issue #901).
252#[derive(Debug, Clone, Error)]
253#[error("{message}")]
254#[non_exhaustive]
255pub struct ValidationError {
256    /// Error code.
257    pub code: ErrorCode,
258    /// Error message.
259    pub message: String,
260    /// Date of the directive that caused the error.
261    pub date: NaiveDate,
262    /// Additional context.
263    pub context: Option<String>,
264    /// Advisory note attached to the error — typically used to help users
265    /// diagnose the underlying cause (e.g. "this directive was synthesized
266    /// by a plugin"). Unlike [`Self::context`], which describes data tied
267    /// to the error, the note describes something about its *origin*.
268    pub note: Option<String>,
269    /// Source span (byte offsets within the file).
270    pub span: Option<Span>,
271    /// Source file ID (index into `SourceMap`).
272    /// Uses `u16` to minimize struct size (max 65,535 files).
273    pub file_id: Option<u16>,
274}
275
276impl ValidationError {
277    /// Create a new validation error without source location.
278    #[must_use]
279    pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
280        Self {
281            code,
282            message: message.into(),
283            date,
284            context: None,
285            note: None,
286            span: None,
287            file_id: None,
288        }
289    }
290
291    /// Create a new validation error with source location from a spanned directive.
292    #[must_use]
293    pub fn with_location<T>(
294        code: ErrorCode,
295        message: impl Into<String>,
296        date: NaiveDate,
297        spanned: &Spanned<T>,
298    ) -> Self {
299        Self {
300            code,
301            message: message.into(),
302            date,
303            context: None,
304            note: None,
305            span: Some(spanned.span),
306            file_id: Some(spanned.file_id),
307        }
308    }
309
310    /// Add context to this error.
311    #[must_use]
312    pub fn with_context(mut self, context: impl Into<String>) -> Self {
313        self.context = Some(context.into());
314        self
315    }
316
317    /// Attach an advisory note to this error (builder pattern).
318    #[must_use]
319    pub fn with_note(mut self, note: impl Into<String>) -> Self {
320        self.note = Some(note.into());
321        self
322    }
323
324    /// Set the source location for this error (builder pattern).
325    ///
326    /// Use this to add location info to an existing error. For creating
327    /// new errors with location, prefer [`Self::with_location`] instead.
328    #[must_use]
329    pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
330        self.span = Some(spanned.span);
331        self.file_id = Some(spanned.file_id);
332        self
333    }
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn error_codes_documented_in_spec() {
342        // Drift guard: every `ErrorCode` must be documented in the validation
343        // spec. (The spec may also carry codes emitted by other crates — e.g.
344        // loader include errors E9001/E9002 — so this is a subset check, not
345        // strict equality.) Codes are backtick-wrapped in the spec (`**Code:**
346        // `E1001``), so the backtick delimiters keep `E1001` from matching
347        // inside `E10001`.
348        // The spec lives at the workspace root (`spec/core/validation.md`),
349        // OUTSIDE this crate, so it is not packaged to crates.io. Read it at
350        // runtime relative to `CARGO_MANIFEST_DIR` and skip when it is absent —
351        // e.g. `cargo test` on the published crate, which the Nix release channel
352        // runs — rather than `include_str!`-ing it at compile time, which would
353        // fail to build the published crate's tests (broke the Nix release
354        // channel on 0.17.x).
355        let spec_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/core/validation.md");
356        let Ok(spec) = std::fs::read_to_string(spec_path) else {
357            eprintln!(
358                "skipping error_codes_documented_in_spec: {spec_path} not present (published-crate build)"
359            );
360            return;
361        };
362        let missing: Vec<&str> = ErrorCode::ALL
363            .iter()
364            .map(ErrorCode::code)
365            .filter(|code| !spec.contains(&format!("`{code}`")))
366            .collect();
367        assert!(
368            missing.is_empty(),
369            "error codes missing from spec/core/validation.md: {missing:?}"
370        );
371    }
372
373    #[test]
374    fn all_lists_distinct_codes() {
375        // Cheap completeness/dup guard for `ALL`: every code string is unique.
376        let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
377        let n = codes.len();
378        codes.sort_unstable();
379        codes.dedup();
380        assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
381    }
382
383    #[test]
384    fn invalid_account_name_is_parse_phase() {
385        // E1005 is a lexical/structural account-name check and must be
386        // reported as a parse-phase diagnostic, matching Python beancount.
387        assert!(ErrorCode::InvalidAccountName.is_parse_phase());
388    }
389
390    #[test]
391    fn other_account_errors_are_validate_phase() {
392        // Lifecycle errors remain semantic (validate-phase) concerns.
393        assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
394        assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
395        assert!(!ErrorCode::AccountClosed.is_parse_phase());
396    }
397
398    #[test]
399    fn non_account_errors_are_validate_phase() {
400        assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
401        assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
402        assert!(!ErrorCode::UnknownOption.is_parse_phase());
403    }
404}