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    /// E4004: Arithmetic exceeded the representable decimal range.
60    ArithmeticOverflow,
61    /// E4005: Cost amount is negative (cost must be non-negative).
62    NegativeCost,
63
64    // === Currency Errors (E5xxx) ===
65    /// E5001: Currency not declared (when strict mode enabled).
66    UndeclaredCurrency,
67    /// E5002: Currency not allowed in account.
68    CurrencyNotAllowed,
69    /// E5003: Invalid `precision` metadata on commodity directive (warning).
70    InvalidPrecisionMetadata,
71
72    // === Budget Errors (E11xxx) ===
73    /// E11001: Malformed `custom "budget"` directive (warning).
74    MalformedBudget,
75
76    // === Option Errors (E7xxx) ===
77    /// E7001: Unknown option name.
78    UnknownOption,
79    /// E7002: Invalid option value.
80    InvalidOptionValue,
81    /// E7003: Duplicate non-repeatable option.
82    DuplicateOption,
83
84    // === Document Errors (E8xxx) ===
85    /// E8001: Document file not found.
86    DocumentNotFound,
87
88    // === Date Errors (E10xxx) ===
89    /// E10002: Entry dated in the future (warning).
90    FutureDate,
91}
92
93impl ErrorCode {
94    /// The code for an inventory-level booking failure.
95    ///
96    /// CANONICAL: the LSP surfaces booking errors it caught itself, and the
97    /// Late validator surfaces the ones it provokes by replaying reductions —
98    /// two call sites, one classification. They had byte-identical `match`
99    /// arms; a reclassification applied to one and not the other would give
100    /// the same ledger different codes in the editor and on the command line
101    /// (CLAUDE.md, Canonical-Function Discipline).
102    #[must_use]
103    pub const fn for_booking_error(err: &rustledger_core::BookingError) -> Self {
104        use rustledger_core::BookingError as B;
105        match err {
106            B::Overflow(_) => Self::ArithmeticOverflow,
107            B::InsufficientUnits { .. } => Self::InsufficientUnits,
108            B::AmbiguousMatch { .. } => Self::AmbiguousLotMatch,
109            // A merge checksum failure means the pool booking recorded is not
110            // the pool application produced, which is a lot-matching failure
111            // from the ledger author's point of view.
112            B::NoMatchingLot { .. } | B::CurrencyMismatch { .. } | B::MergeMismatch { .. } => {
113                Self::NoMatchingLot
114            }
115        }
116    }
117}
118
119impl ErrorCode {
120    /// Every error-code variant. Used by the spec-drift guard test (and any
121    /// catalog enumeration). MUST list every variant — keep it in sync with the
122    /// enum; the exhaustive [`code`](Self::code) match is the compiler-enforced
123    /// source of truth for the code strings themselves.
124    pub const ALL: &'static [Self] = &[
125        Self::AccountNotOpen,
126        Self::AccountAlreadyOpen,
127        Self::AccountClosed,
128        Self::AccountCloseNotEmpty,
129        Self::InvalidAccountName,
130        Self::BalanceAssertionFailed,
131        Self::BalanceToleranceExceeded,
132        Self::PadWithoutBalance,
133        Self::MultiplePadForBalance,
134        Self::TransactionUnbalanced,
135        Self::MultipleInterpolation,
136        Self::NoPostings,
137        Self::SinglePosting,
138        Self::NoMatchingLot,
139        Self::InsufficientUnits,
140        Self::AmbiguousLotMatch,
141        Self::ArithmeticOverflow,
142        Self::NegativeCost,
143        Self::UndeclaredCurrency,
144        Self::CurrencyNotAllowed,
145        Self::InvalidPrecisionMetadata,
146        Self::MalformedBudget,
147        Self::UnknownOption,
148        Self::InvalidOptionValue,
149        Self::DuplicateOption,
150        Self::DocumentNotFound,
151        Self::FutureDate,
152    ];
153
154    /// Get the error code string (e.g., "E1001").
155    #[must_use]
156    pub const fn code(&self) -> &'static str {
157        match self {
158            // Account errors
159            Self::AccountNotOpen => "E1001",
160            Self::AccountAlreadyOpen => "E1002",
161            Self::AccountClosed => "E1003",
162            Self::AccountCloseNotEmpty => "E1004",
163            Self::InvalidAccountName => "E1005",
164            // Balance errors
165            Self::BalanceAssertionFailed => "E2001",
166            Self::BalanceToleranceExceeded => "E2002",
167            Self::PadWithoutBalance => "E2003",
168            Self::MultiplePadForBalance => "E2004",
169            // Transaction errors
170            Self::TransactionUnbalanced => "E3001",
171            Self::MultipleInterpolation => "E3002",
172            Self::NoPostings => "E3003",
173            Self::SinglePosting => "E3004",
174            // Booking errors
175            Self::NoMatchingLot => "E4001",
176            Self::InsufficientUnits => "E4002",
177            Self::AmbiguousLotMatch => "E4003",
178            Self::ArithmeticOverflow => "E4004",
179            Self::NegativeCost => "E4005",
180            // Currency errors
181            Self::UndeclaredCurrency => "E5001",
182            Self::CurrencyNotAllowed => "E5002",
183            Self::InvalidPrecisionMetadata => "E5003",
184            // Budget errors. E6xxx is RESERVED for metadata errors by
185            // docs/reference/errors.md and left alone, even though nothing uses
186            // it yet — the one metadata error that exists (E5003) was filed
187            // under currency. Taking a reserved range quietly is how two
188            // categories end up sharing one prefix.
189            Self::MalformedBudget => "E11001",
190            // Option errors
191            Self::UnknownOption => "E7001",
192            Self::InvalidOptionValue => "E7002",
193            Self::DuplicateOption => "E7003",
194            // Document errors
195            Self::DocumentNotFound => "E8001",
196            // Date errors
197            Self::FutureDate => "E10002",
198        }
199    }
200
201    /// Check if this is a warning (not an error).
202    #[must_use]
203    pub const fn is_warning(&self) -> bool {
204        matches!(
205            self,
206            Self::FutureDate
207                | Self::SinglePosting
208                | Self::AccountCloseNotEmpty
209                | Self::InvalidPrecisionMetadata
210                | Self::MalformedBudget
211        )
212    }
213
214    /// Whether this diagnostic is advisory-only and must NOT be surfaced by
215    /// `check` (which mirrors `bean-check`). Python beancount does not flag
216    /// closing an account with a residual balance, so `check` stays silent; the
217    /// advisory is surfaced instead by `rledger lint closed-nonempty`.
218    #[must_use]
219    pub const fn is_advisory_only(&self) -> bool {
220        matches!(self, Self::AccountCloseNotEmpty)
221    }
222
223    /// Parse a user-supplied code string (`"E2001"`, `"e2001"`, or bare
224    /// `"2001"`) into its variant. Backs `rledger explain`.
225    #[must_use]
226    pub fn from_code(code: &str) -> Option<Self> {
227        let digits = code
228            .trim()
229            .strip_prefix(['E', 'e'])
230            .unwrap_or_else(|| code.trim());
231        let normalized = format!("E{digits}");
232        Self::ALL.iter().find(|c| c.code() == normalized).copied()
233    }
234
235    /// A short human title for the code (one line). Backs `rledger explain`.
236    #[must_use]
237    pub const fn title(&self) -> &'static str {
238        match self {
239            Self::AccountNotOpen => "Account used before it was opened",
240            Self::AccountAlreadyOpen => "Duplicate open directive for an account",
241            Self::AccountClosed => "Account used after it was closed",
242            Self::AccountCloseNotEmpty => "Account closed with a non-zero balance",
243            Self::InvalidAccountName => "Invalid account name",
244            Self::BalanceAssertionFailed => "Balance assertion failed",
245            Self::BalanceToleranceExceeded => "Balance exceeds explicit tolerance",
246            Self::PadWithoutBalance => "Pad without a subsequent balance assertion",
247            Self::MultiplePadForBalance => "Multiple pads for the same balance assertion",
248            Self::TransactionUnbalanced => "Transaction does not balance",
249            Self::MultipleInterpolation => "Multiple postings missing amounts for one currency",
250            Self::NoPostings => "Transaction has no postings",
251            Self::SinglePosting => "Transaction has a single posting",
252            Self::NoMatchingLot => "No matching lot for reduction",
253            Self::InsufficientUnits => "Not enough units in matching lots",
254            Self::AmbiguousLotMatch => "Ambiguous lot match under STRICT booking",
255            Self::ArithmeticOverflow => "Amount exceeds the representable range",
256            Self::NegativeCost => "Negative cost",
257            Self::UndeclaredCurrency => "Currency used without a commodity declaration",
258            Self::CurrencyNotAllowed => "Currency not allowed in this account",
259            Self::InvalidPrecisionMetadata => "Invalid precision metadata on commodity",
260            Self::MalformedBudget => "Malformed budget directive",
261            Self::UnknownOption => "Unknown option name",
262            Self::InvalidOptionValue => "Invalid option value",
263            Self::DuplicateOption => "Non-repeatable option given more than once",
264            Self::DocumentNotFound => "Document file not found",
265            Self::FutureDate => "Directive dated in the future",
266        }
267    }
268
269    /// A detailed explanation of the code — what it means, its common cause,
270    /// and how to fix it. Backs `rledger explain`, mirroring
271    /// `rustc --explain`.
272    ///
273    /// Kept as code constants (not `include_str!` from `spec/core/`) so the
274    /// binary is self-contained: published crates don't package `spec/`, and
275    /// the Nix flake's source filter strips it. The exhaustive match means
276    /// adding a variant forces adding its explanation, and the
277    /// `error_codes_documented_in_spec` test guards that every code is also
278    /// documented in the spec.
279    #[must_use]
280    pub const fn explanation(&self) -> &'static str {
281        match self {
282            Self::AccountNotOpen => {
283                "A posting or directive references an account with no prior `open` \
284                 directive.\n\nEvery account must be opened on or before the date it is \
285                 first used:\n\n    2024-01-01 open Assets:Bank:Checking USD\n\nFix: add \
286                 an `open` directive dated on or before the first use, or correct a \
287                 misspelled account name."
288            }
289            Self::AccountAlreadyOpen => {
290                "An `open` directive targets an account that is already open.\n\nThis \
291                 is usually a duplicated line — often the same `open` appearing in both \
292                 a main file and an `include`d file.\n\nFix: remove the duplicate \
293                 `open` (keep the earliest one)."
294            }
295            Self::AccountClosed => {
296                "A posting or directive references an account after its `close` \
297                 directive.\n\nFix: move the transaction before the close date, remove \
298                 the `close`, or use a different account."
299            }
300            Self::AccountCloseNotEmpty => {
301                "A `close` directive targets an account that still holds a non-zero \
302                 balance.\n\nAdvisory only: `check` stays silent to match `bean-check`; \
303                 surface it on demand with `rledger lint closed-nonempty`.\n\nFix: zero \
304                 the account (transfer the residual) before closing it."
305            }
306            Self::InvalidAccountName => {
307                "An account name does not match the required pattern.\n\nAccount names \
308                 are colon-separated capitalized components rooted at one of the five \
309                 account types (Assets, Liabilities, Equity, Income, Expenses — \
310                 renameable via `option \"name_assets\"` etc.), e.g. \
311                 `Assets:Bank:Checking`.\n\nFix: rename the account to match the \
312                 pattern."
313            }
314            Self::BalanceAssertionFailed => {
315                "A `balance` assertion does not match the computed balance of the \
316                 account (including its sub-accounts) at that date.\n\nThe comparison \
317                 uses a tolerance inferred from the asserted amount's precision.\n\n\
318                 Fix: correct the asserted amount, add the missing transactions, or \
319                 insert a `pad` directive to absorb the difference. The reported \
320                 difference is the exact discrepancy."
321            }
322            Self::BalanceToleranceExceeded => {
323                "A `balance` assertion with an explicit tolerance, e.g. \
324                 `balance Assets:Cash 100.00 ~ 0.05 USD`, differs from the computed \
325                 balance by more than that tolerance.\n\nFix: correct the amount, \
326                 widen the explicit tolerance, or add the missing transactions."
327            }
328            Self::PadWithoutBalance => {
329                "A `pad` directive is never consumed by a later `balance` assertion \
330                 for that account and currency.\n\nA pad means \"insert whatever \
331                 amount makes the NEXT balance assertion true\" — without that \
332                 balance it does nothing.\n\nFix: add the `balance` assertion after \
333                 the pad, or delete the pad."
334            }
335            Self::MultiplePadForBalance => {
336                "More than one `pad` directive is pending for the same account and \
337                 currency before a single `balance` assertion — it is ambiguous which \
338                 pad should absorb the difference.\n\nFix: keep one pad per \
339                 account/currency between consecutive balance assertions."
340            }
341            Self::TransactionUnbalanced => {
342                "The weights of a transaction's postings do not sum to zero per \
343                 currency (beyond the inferred tolerance).\n\nA posting's weight is \
344                 its amount, converted through its cost (`{...}`) or price \
345                 (`@`/`@@`) when present.\n\nFix: correct the amounts, or leave \
346                 exactly one posting's amount blank and rustledger will interpolate \
347                 it. The reported residual is the exact imbalance."
348            }
349            Self::MultipleInterpolation => {
350                "More than one posting in the same currency has no amount — only one \
351                 blank posting per currency can be interpolated from the others.\n\n\
352                 Fix: fill in amounts so at most one posting per currency is elided."
353            }
354            Self::NoPostings => {
355                "Reserved for a transaction with zero postings.\n\nNever emitted in \
356                 practice: rustledger (like Python beancount) treats a posting-less \
357                 transaction as a structurally-valid no-op."
358            }
359            Self::SinglePosting => {
360                "A transaction has exactly one posting, which cannot balance on its \
361                 own (warning).\n\nFix: add the offsetting posting(s), or elide the \
362                 second amount to interpolate it."
363            }
364            Self::NoMatchingLot => {
365                "A cost reduction (e.g. a sale, `Assets:Stock -5 X {...}`) specifies \
366                 a cost, date, or label that matches no lot held in the account's \
367                 inventory.\n\nFix: check the cost spec against the actual holdings; \
368                 `rledger query` with `cost_label`/`cost_date` columns shows the \
369                 lots."
370            }
371            Self::InsufficientUnits => {
372                "A reduction requests more units than the matching lots hold (e.g. \
373                 selling 10 when 5 are held).\n\nA failed reduction leaves the \
374                 inventory untouched.\n\nFix: reduce the sold quantity, or check for \
375                 a missing purchase transaction."
376            }
377            Self::AmbiguousLotMatch => {
378                "Under STRICT booking (the default), a reduction's cost spec matches \
379                 more than one lot, and rustledger refuses to guess.\n\nFix: \
380                 disambiguate with the lot's cost `{10.00 USD}`, date `{2024-01-02}`, \
381                 or label `{\"lot-a\"}` — or open the account with a non-strict \
382                 method: `2024-01-01 open Assets:Stock \"FIFO\"`."
383            }
384            Self::ArithmeticOverflow => {
385                "An amount, or a running total, is larger than rledger's decimal type \
386                 can represent (about ±7.9×10²⁸ — a 96-bit type with ~28 significant \
387                 digits).\n\nrledger reports this instead of rounding or clamping: a \
388                 clamped figure would be printed as if it were exact, and two clamped \
389                 figures of opposite sign cancel to zero, which would make an \
390                 unbalanced transaction look balanced.\n\nFix: split the transaction, \
391                 or use larger units (thousands, millions) for the commodity."
392            }
393            Self::NegativeCost => {
394                "A posting's cost amount is negative — a cost basis must be \
395                 non-negative.\n\nFix: check the sign of the cost (the units carry \
396                 the sign of a sale, not the cost)."
397            }
398            Self::UndeclaredCurrency => {
399                "A currency is used but never declared with a `commodity` directive, \
400                 and commodity declarations are required (strict commodity mode).\n\n\
401                 Fix: add `YYYY-MM-DD commodity CUR`, or disable the strict \
402                 requirement."
403            }
404            Self::CurrencyNotAllowed => {
405                "A posting or `balance` assertion uses a currency outside the list \
406                 the account was opened with (`open Assets:Cash USD` constrains the \
407                 account to USD).\n\nFix: use an allowed currency, or extend the \
408                 currency list on the `open` directive. An `open` with no currencies \
409                 allows all."
410            }
411            Self::InvalidPrecisionMetadata => {
412                "A `commodity` directive carries a `precision:` metadata value that \
413                 does not parse as a non-negative integer (warning). The declaration \
414                 is ignored; display precision falls back to \
415                 `option \"display_precision\"`, then to inference.\n\nFix: use e.g. \
416                 `precision: 2`."
417            }
418            Self::UnknownOption => {
419                "An `option` directive names an option rustledger does not recognize \
420                 (warning; the option is ignored).\n\nFix: check the option name \
421                 against the options documentation — it may be misspelled or \
422                 unsupported."
423            }
424            Self::InvalidOptionValue => {
425                "An `option` directive has a value that does not parse for that \
426                 option's type (e.g. a non-numeric \
427                 `inferred_tolerance_multiplier`).\n\nFix: correct the value per the \
428                 options documentation."
429            }
430            Self::DuplicateOption => {
431                "A non-repeatable option is specified more than once (warning; the \
432                 last value wins).\n\nFix: keep a single occurrence."
433            }
434            Self::DocumentNotFound => {
435                "A `document` directive references a file that does not exist. \
436                 Relative paths resolve against the directory of the source file \
437                 containing the directive (matching `include`).\n\nFix: correct the \
438                 path, or remove the directive."
439            }
440            Self::MalformedBudget => {
441                "A `custom \"budget\"` directive that is recognizably a budget \
442                 carries content rledger cannot use (warning).\n\nBudgets follow \
443                 Fava's convention: `<date> custom \"budget\" <Account> \
444                 \"<interval>\" <amount> <CCY>`, where interval is daily, weekly, \
445                 monthly, quarterly or yearly. A trailing quoted note is fine; a \
446                 trailing second figure is reported, though the budget still \
447                 applies at the first.\n\nFix: correct the directive. This is not \
448                 raised for a `custom \"budget\"` belonging to other tooling: a \
449                 payload with neither a real interval keyword nor an \
450                 account-and-amount pair is left alone everywhere, since \
451                 `custom` is beancount's open extension point."
452            }
453            Self::FutureDate => {
454                "A directive is dated in the future relative to today (warning).\n\n\
455                 Fix: correct the date — or ignore the warning if the future dating \
456                 is intentional (e.g. scheduled entries)."
457            }
458        }
459    }
460
461    /// Get the severity level.
462    #[must_use]
463    pub const fn severity(&self) -> Severity {
464        // No code maps to `Severity::Info`. E10001 was the only one, and it was
465        // unreachable — see #1970. The level is kept on `Severity` because it
466        // is public and consumers match it exhaustively.
467        if self.is_warning() {
468            Severity::Warning
469        } else {
470            Severity::Error
471        }
472    }
473
474    /// Whether this error represents a parse-phase concern rather than a
475    /// semantic/validate-phase concern.
476    ///
477    /// Some checks — notably account-name structure (E1005) — are lexical in
478    /// nature and are conceptually part of parsing, even though rustledger
479    /// currently runs them during validation because the set of valid account
480    /// roots is not known until options have been resolved. Python beancount's
481    /// parser rejects these inputs at parse time, so we tag them as parse-phase
482    /// for consumers that distinguish the two (e.g. the conformance harness).
483    #[must_use]
484    pub const fn is_parse_phase(&self) -> bool {
485        matches!(self, Self::InvalidAccountName)
486    }
487}
488
489/// Whether a rendered diagnostic code string (e.g. `"E1004"`) is advisory-only.
490///
491/// The string-keyed counterpart to [`ErrorCode::is_advisory_only`], for
492/// consumers (the CLI `check`/`lint` split) that only carry the code string.
493/// Keeping it here means the set of advisory-only codes lives in one place.
494#[must_use]
495pub fn is_advisory_only_code(code: &str) -> bool {
496    code == ErrorCode::AccountCloseNotEmpty.code()
497}
498
499/// Severity level for validation messages.
500#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
501pub enum Severity {
502    /// Ledger is invalid.
503    Error,
504    /// Suspicious but valid.
505    Warning,
506    /// Informational only.
507    ///
508    /// No [`ErrorCode`] currently maps here. E10001 did, and it could never be
509    /// emitted (#1970); the level is retained because `Severity` is public and
510    /// the LSP maps it to `DiagnosticSeverity::INFORMATION`.
511    Info,
512}
513
514impl std::fmt::Display for ErrorCode {
515    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516        write!(f, "{}", self.code())
517    }
518}
519
520/// A validation error.
521///
522/// The `Display` impl emits just the message text (no `[E1234]` prefix).
523/// CLI and IDE renderers are expected to prepend the error code themselves,
524/// which avoids the double-tagging seen in older output like
525/// `error[E3001]: [E3001] ...` (see issue #901).
526#[derive(Debug, Clone, Error)]
527#[error("{message}")]
528#[non_exhaustive]
529pub struct ValidationError {
530    /// Error code.
531    pub code: ErrorCode,
532    /// Error message.
533    pub message: String,
534    /// Date of the directive that caused the error.
535    pub date: NaiveDate,
536    /// Additional context.
537    pub context: Option<String>,
538    /// Advisory note attached to the error — typically used to help users
539    /// diagnose the underlying cause (e.g. "this directive was synthesized
540    /// by a plugin"). Unlike [`Self::context`], which describes data tied
541    /// to the error, the note describes something about its *origin*.
542    pub note: Option<String>,
543    /// Source span (byte offsets within the file).
544    pub span: Option<Span>,
545    /// Source file ID (index into `SourceMap`).
546    /// Uses `u16` to minimize struct size (max 65,535 files).
547    pub file_id: Option<u16>,
548}
549
550impl ValidationError {
551    /// Create a new validation error without source location.
552    #[must_use]
553    pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
554        Self {
555            code,
556            message: message.into(),
557            date,
558            context: None,
559            note: None,
560            span: None,
561            file_id: None,
562        }
563    }
564
565    /// Create a new validation error with source location from a spanned directive.
566    #[must_use]
567    pub fn with_location<T>(
568        code: ErrorCode,
569        message: impl Into<String>,
570        date: NaiveDate,
571        spanned: &Spanned<T>,
572    ) -> Self {
573        Self {
574            code,
575            message: message.into(),
576            date,
577            context: None,
578            note: None,
579            span: Some(spanned.span),
580            file_id: Some(spanned.file_id),
581        }
582    }
583
584    /// Add context to this error.
585    #[must_use]
586    pub fn with_context(mut self, context: impl Into<String>) -> Self {
587        self.context = Some(context.into());
588        self
589    }
590
591    /// Attach an advisory note to this error (builder pattern).
592    #[must_use]
593    pub fn with_note(mut self, note: impl Into<String>) -> Self {
594        self.note = Some(note.into());
595        self
596    }
597
598    /// Set the source location for this error (builder pattern).
599    ///
600    /// Use this to add location info to an existing error. For creating
601    /// new errors with location, prefer [`Self::with_location`] instead.
602    #[must_use]
603    pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
604        self.span = Some(spanned.span);
605        self.file_id = Some(spanned.file_id);
606        self
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    #[test]
615    fn error_codes_documented_in_spec() {
616        // Drift guard: every `ErrorCode` must be documented in the validation
617        // spec. (The spec may also carry codes emitted by other crates — e.g.
618        // loader include errors E9001/E9002 — so this is a subset check, not
619        // strict equality.) Codes are backtick-wrapped in the spec (`**Code:**
620        // `E1001``), so the backtick delimiters keep a shorter code from
621        // matching inside a longer one.
622        // The spec lives at the workspace root (`spec/core/validation.md`),
623        // OUTSIDE this crate, so it is not packaged to crates.io. Read it at
624        // runtime relative to `CARGO_MANIFEST_DIR` and skip when it is absent —
625        // e.g. `cargo test` on the published crate, which the Nix release channel
626        // runs — rather than `include_str!`-ing it at compile time, which would
627        // fail to build the published crate's tests (broke the Nix release
628        // channel on 0.17.x).
629        let spec_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/core/validation.md");
630        let Ok(spec) = std::fs::read_to_string(spec_path) else {
631            eprintln!(
632                "skipping error_codes_documented_in_spec: {spec_path} not present (published-crate build)"
633            );
634            return;
635        };
636        let missing: Vec<&str> = ErrorCode::ALL
637            .iter()
638            .map(ErrorCode::code)
639            .filter(|code| !spec.contains(&format!("`{code}`")))
640            .collect();
641        assert!(
642            missing.is_empty(),
643            "error codes missing from spec/core/validation.md: {missing:?}"
644        );
645    }
646
647    #[test]
648    fn all_lists_distinct_codes() {
649        // Cheap completeness/dup guard for `ALL`: every code string is unique.
650        let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
651        let n = codes.len();
652        codes.sort_unstable();
653        codes.dedup();
654        assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
655    }
656
657    #[test]
658    fn invalid_account_name_is_parse_phase() {
659        // E1005 is a lexical/structural account-name check and must be
660        // reported as a parse-phase diagnostic, matching Python beancount.
661        assert!(ErrorCode::InvalidAccountName.is_parse_phase());
662    }
663
664    #[test]
665    fn other_account_errors_are_validate_phase() {
666        // Lifecycle errors remain semantic (validate-phase) concerns.
667        assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
668        assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
669        assert!(!ErrorCode::AccountClosed.is_parse_phase());
670    }
671
672    #[test]
673    fn non_account_errors_are_validate_phase() {
674        assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
675        assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
676        assert!(!ErrorCode::UnknownOption.is_parse_phase());
677    }
678}