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    /// Parse a user-supplied code string (`"E2001"`, `"e2001"`, or bare
193    /// `"2001"`) into its variant. Backs `rledger explain`.
194    #[must_use]
195    pub fn from_code(code: &str) -> Option<Self> {
196        let digits = code
197            .trim()
198            .strip_prefix(['E', 'e'])
199            .unwrap_or_else(|| code.trim());
200        let normalized = format!("E{digits}");
201        Self::ALL.iter().find(|c| c.code() == normalized).copied()
202    }
203
204    /// A short human title for the code (one line). Backs `rledger explain`.
205    #[must_use]
206    pub const fn title(&self) -> &'static str {
207        match self {
208            Self::AccountNotOpen => "Account used before it was opened",
209            Self::AccountAlreadyOpen => "Duplicate open directive for an account",
210            Self::AccountClosed => "Account used after it was closed",
211            Self::AccountCloseNotEmpty => "Account closed with a non-zero balance",
212            Self::InvalidAccountName => "Invalid account name",
213            Self::BalanceAssertionFailed => "Balance assertion failed",
214            Self::BalanceToleranceExceeded => "Balance exceeds explicit tolerance",
215            Self::PadWithoutBalance => "Pad without a subsequent balance assertion",
216            Self::MultiplePadForBalance => "Multiple pads for the same balance assertion",
217            Self::TransactionUnbalanced => "Transaction does not balance",
218            Self::MultipleInterpolation => "Multiple postings missing amounts for one currency",
219            Self::NoPostings => "Transaction has no postings",
220            Self::SinglePosting => "Transaction has a single posting",
221            Self::NoMatchingLot => "No matching lot for reduction",
222            Self::InsufficientUnits => "Not enough units in matching lots",
223            Self::AmbiguousLotMatch => "Ambiguous lot match under STRICT booking",
224            Self::NegativeCost => "Negative cost",
225            Self::UndeclaredCurrency => "Currency used without a commodity declaration",
226            Self::CurrencyNotAllowed => "Currency not allowed in this account",
227            Self::InvalidPrecisionMetadata => "Invalid precision metadata on commodity",
228            Self::UnknownOption => "Unknown option name",
229            Self::InvalidOptionValue => "Invalid option value",
230            Self::DuplicateOption => "Non-repeatable option given more than once",
231            Self::DocumentNotFound => "Document file not found",
232            Self::DateOutOfOrder => "Directive date out of order",
233            Self::FutureDate => "Directive dated in the future",
234        }
235    }
236
237    /// A detailed explanation of the code — what it means, its common cause,
238    /// and how to fix it. Backs `rledger explain`, mirroring
239    /// `rustc --explain`.
240    ///
241    /// Kept as code constants (not `include_str!` from `spec/core/`) so the
242    /// binary is self-contained: published crates don't package `spec/`, and
243    /// the Nix flake's source filter strips it. The exhaustive match means
244    /// adding a variant forces adding its explanation, and the
245    /// `error_codes_documented_in_spec` test guards that every code is also
246    /// documented in the spec.
247    #[must_use]
248    pub const fn explanation(&self) -> &'static str {
249        match self {
250            Self::AccountNotOpen => {
251                "A posting or directive references an account with no prior `open` \
252                 directive.\n\nEvery account must be opened on or before the date it is \
253                 first used:\n\n    2024-01-01 open Assets:Bank:Checking USD\n\nFix: add \
254                 an `open` directive dated on or before the first use, or correct a \
255                 misspelled account name."
256            }
257            Self::AccountAlreadyOpen => {
258                "An `open` directive targets an account that is already open.\n\nThis \
259                 is usually a duplicated line — often the same `open` appearing in both \
260                 a main file and an `include`d file.\n\nFix: remove the duplicate \
261                 `open` (keep the earliest one)."
262            }
263            Self::AccountClosed => {
264                "A posting or directive references an account after its `close` \
265                 directive.\n\nFix: move the transaction before the close date, remove \
266                 the `close`, or use a different account."
267            }
268            Self::AccountCloseNotEmpty => {
269                "A `close` directive targets an account that still holds a non-zero \
270                 balance.\n\nAdvisory only: `check` stays silent to match `bean-check`; \
271                 surface it on demand with `rledger lint closed-nonempty`.\n\nFix: zero \
272                 the account (transfer the residual) before closing it."
273            }
274            Self::InvalidAccountName => {
275                "An account name does not match the required pattern.\n\nAccount names \
276                 are colon-separated capitalized components rooted at one of the five \
277                 account types (Assets, Liabilities, Equity, Income, Expenses — \
278                 renameable via `option \"name_assets\"` etc.), e.g. \
279                 `Assets:Bank:Checking`.\n\nFix: rename the account to match the \
280                 pattern."
281            }
282            Self::BalanceAssertionFailed => {
283                "A `balance` assertion does not match the computed balance of the \
284                 account (including its sub-accounts) at that date.\n\nThe comparison \
285                 uses a tolerance inferred from the asserted amount's precision.\n\n\
286                 Fix: correct the asserted amount, add the missing transactions, or \
287                 insert a `pad` directive to absorb the difference. The reported \
288                 difference is the exact discrepancy."
289            }
290            Self::BalanceToleranceExceeded => {
291                "A `balance` assertion with an explicit tolerance, e.g. \
292                 `balance Assets:Cash 100.00 ~ 0.05 USD`, differs from the computed \
293                 balance by more than that tolerance.\n\nFix: correct the amount, \
294                 widen the explicit tolerance, or add the missing transactions."
295            }
296            Self::PadWithoutBalance => {
297                "A `pad` directive is never consumed by a later `balance` assertion \
298                 for that account and currency.\n\nA pad means \"insert whatever \
299                 amount makes the NEXT balance assertion true\" — without that \
300                 balance it does nothing.\n\nFix: add the `balance` assertion after \
301                 the pad, or delete the pad."
302            }
303            Self::MultiplePadForBalance => {
304                "More than one `pad` directive is pending for the same account and \
305                 currency before a single `balance` assertion — it is ambiguous which \
306                 pad should absorb the difference.\n\nFix: keep one pad per \
307                 account/currency between consecutive balance assertions."
308            }
309            Self::TransactionUnbalanced => {
310                "The weights of a transaction's postings do not sum to zero per \
311                 currency (beyond the inferred tolerance).\n\nA posting's weight is \
312                 its amount, converted through its cost (`{...}`) or price \
313                 (`@`/`@@`) when present.\n\nFix: correct the amounts, or leave \
314                 exactly one posting's amount blank and rustledger will interpolate \
315                 it. The reported residual is the exact imbalance."
316            }
317            Self::MultipleInterpolation => {
318                "More than one posting in the same currency has no amount — only one \
319                 blank posting per currency can be interpolated from the others.\n\n\
320                 Fix: fill in amounts so at most one posting per currency is elided."
321            }
322            Self::NoPostings => {
323                "Reserved for a transaction with zero postings.\n\nNever emitted in \
324                 practice: rustledger (like Python beancount) treats a posting-less \
325                 transaction as a structurally-valid no-op."
326            }
327            Self::SinglePosting => {
328                "A transaction has exactly one posting, which cannot balance on its \
329                 own (warning).\n\nFix: add the offsetting posting(s), or elide the \
330                 second amount to interpolate it."
331            }
332            Self::NoMatchingLot => {
333                "A cost reduction (e.g. a sale, `Assets:Stock -5 X {...}`) specifies \
334                 a cost, date, or label that matches no lot held in the account's \
335                 inventory.\n\nFix: check the cost spec against the actual holdings; \
336                 `rledger query` with `cost_label`/`cost_date` columns shows the \
337                 lots."
338            }
339            Self::InsufficientUnits => {
340                "A reduction requests more units than the matching lots hold (e.g. \
341                 selling 10 when 5 are held).\n\nA failed reduction leaves the \
342                 inventory untouched.\n\nFix: reduce the sold quantity, or check for \
343                 a missing purchase transaction."
344            }
345            Self::AmbiguousLotMatch => {
346                "Under STRICT booking (the default), a reduction's cost spec matches \
347                 more than one lot, and rustledger refuses to guess.\n\nFix: \
348                 disambiguate with the lot's cost `{10.00 USD}`, date `{2024-01-02}`, \
349                 or label `{\"lot-a\"}` — or open the account with a non-strict \
350                 method: `2024-01-01 open Assets:Stock \"FIFO\"`."
351            }
352            Self::NegativeCost => {
353                "A posting's cost amount is negative — a cost basis must be \
354                 non-negative.\n\nFix: check the sign of the cost (the units carry \
355                 the sign of a sale, not the cost)."
356            }
357            Self::UndeclaredCurrency => {
358                "A currency is used but never declared with a `commodity` directive, \
359                 and commodity declarations are required (strict commodity mode).\n\n\
360                 Fix: add `YYYY-MM-DD commodity CUR`, or disable the strict \
361                 requirement."
362            }
363            Self::CurrencyNotAllowed => {
364                "A posting or `balance` assertion uses a currency outside the list \
365                 the account was opened with (`open Assets:Cash USD` constrains the \
366                 account to USD).\n\nFix: use an allowed currency, or extend the \
367                 currency list on the `open` directive. An `open` with no currencies \
368                 allows all."
369            }
370            Self::InvalidPrecisionMetadata => {
371                "A `commodity` directive carries a `precision:` metadata value that \
372                 does not parse as a non-negative integer (warning). The declaration \
373                 is ignored; display precision falls back to \
374                 `option \"display_precision\"`, then to inference.\n\nFix: use e.g. \
375                 `precision: 2`."
376            }
377            Self::UnknownOption => {
378                "An `option` directive names an option rustledger does not recognize \
379                 (warning; the option is ignored).\n\nFix: check the option name \
380                 against the options documentation — it may be misspelled or \
381                 unsupported."
382            }
383            Self::InvalidOptionValue => {
384                "An `option` directive has a value that does not parse for that \
385                 option's type (e.g. a non-numeric \
386                 `inferred_tolerance_multiplier`).\n\nFix: correct the value per the \
387                 options documentation."
388            }
389            Self::DuplicateOption => {
390                "A non-repeatable option is specified more than once (warning; the \
391                 last value wins).\n\nFix: keep a single occurrence."
392            }
393            Self::DocumentNotFound => {
394                "A `document` directive references a file that does not exist. \
395                 Relative paths resolve against the directory of the source file \
396                 containing the directive (matching `include`).\n\nFix: correct the \
397                 path, or remove the directive."
398            }
399            Self::DateOutOfOrder => {
400                "A directive's date is earlier than the preceding directive's date \
401                 in the same file (informational only — directives are sorted before \
402                 processing, so this never changes results).\n\nFix: reorder the \
403                 file chronologically if you care about source order."
404            }
405            Self::FutureDate => {
406                "A directive is dated in the future relative to today (warning).\n\n\
407                 Fix: correct the date — or ignore the warning if the future dating \
408                 is intentional (e.g. scheduled entries)."
409            }
410        }
411    }
412
413    /// Get the severity level.
414    #[must_use]
415    pub const fn severity(&self) -> Severity {
416        if self.is_info() {
417            Severity::Info
418        } else if self.is_warning() {
419            Severity::Warning
420        } else {
421            Severity::Error
422        }
423    }
424
425    /// Whether this error represents a parse-phase concern rather than a
426    /// semantic/validate-phase concern.
427    ///
428    /// Some checks — notably account-name structure (E1005) — are lexical in
429    /// nature and are conceptually part of parsing, even though rustledger
430    /// currently runs them during validation because the set of valid account
431    /// roots is not known until options have been resolved. Python beancount's
432    /// parser rejects these inputs at parse time, so we tag them as parse-phase
433    /// for consumers that distinguish the two (e.g. the conformance harness).
434    #[must_use]
435    pub const fn is_parse_phase(&self) -> bool {
436        matches!(self, Self::InvalidAccountName)
437    }
438}
439
440/// Whether a rendered diagnostic code string (e.g. `"E1004"`) is advisory-only.
441///
442/// The string-keyed counterpart to [`ErrorCode::is_advisory_only`], for
443/// consumers (the CLI `check`/`lint` split) that only carry the code string.
444/// Keeping it here means the set of advisory-only codes lives in one place.
445#[must_use]
446pub fn is_advisory_only_code(code: &str) -> bool {
447    code == ErrorCode::AccountCloseNotEmpty.code()
448}
449
450/// Severity level for validation messages.
451#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
452pub enum Severity {
453    /// Ledger is invalid.
454    Error,
455    /// Suspicious but valid.
456    Warning,
457    /// Informational only.
458    Info,
459}
460
461impl std::fmt::Display for ErrorCode {
462    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
463        write!(f, "{}", self.code())
464    }
465}
466
467/// A validation error.
468///
469/// The `Display` impl emits just the message text (no `[E1234]` prefix).
470/// CLI and IDE renderers are expected to prepend the error code themselves,
471/// which avoids the double-tagging seen in older output like
472/// `error[E3001]: [E3001] ...` (see issue #901).
473#[derive(Debug, Clone, Error)]
474#[error("{message}")]
475#[non_exhaustive]
476pub struct ValidationError {
477    /// Error code.
478    pub code: ErrorCode,
479    /// Error message.
480    pub message: String,
481    /// Date of the directive that caused the error.
482    pub date: NaiveDate,
483    /// Additional context.
484    pub context: Option<String>,
485    /// Advisory note attached to the error — typically used to help users
486    /// diagnose the underlying cause (e.g. "this directive was synthesized
487    /// by a plugin"). Unlike [`Self::context`], which describes data tied
488    /// to the error, the note describes something about its *origin*.
489    pub note: Option<String>,
490    /// Source span (byte offsets within the file).
491    pub span: Option<Span>,
492    /// Source file ID (index into `SourceMap`).
493    /// Uses `u16` to minimize struct size (max 65,535 files).
494    pub file_id: Option<u16>,
495}
496
497impl ValidationError {
498    /// Create a new validation error without source location.
499    #[must_use]
500    pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
501        Self {
502            code,
503            message: message.into(),
504            date,
505            context: None,
506            note: None,
507            span: None,
508            file_id: None,
509        }
510    }
511
512    /// Create a new validation error with source location from a spanned directive.
513    #[must_use]
514    pub fn with_location<T>(
515        code: ErrorCode,
516        message: impl Into<String>,
517        date: NaiveDate,
518        spanned: &Spanned<T>,
519    ) -> Self {
520        Self {
521            code,
522            message: message.into(),
523            date,
524            context: None,
525            note: None,
526            span: Some(spanned.span),
527            file_id: Some(spanned.file_id),
528        }
529    }
530
531    /// Add context to this error.
532    #[must_use]
533    pub fn with_context(mut self, context: impl Into<String>) -> Self {
534        self.context = Some(context.into());
535        self
536    }
537
538    /// Attach an advisory note to this error (builder pattern).
539    #[must_use]
540    pub fn with_note(mut self, note: impl Into<String>) -> Self {
541        self.note = Some(note.into());
542        self
543    }
544
545    /// Set the source location for this error (builder pattern).
546    ///
547    /// Use this to add location info to an existing error. For creating
548    /// new errors with location, prefer [`Self::with_location`] instead.
549    #[must_use]
550    pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
551        self.span = Some(spanned.span);
552        self.file_id = Some(spanned.file_id);
553        self
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560
561    #[test]
562    fn error_codes_documented_in_spec() {
563        // Drift guard: every `ErrorCode` must be documented in the validation
564        // spec. (The spec may also carry codes emitted by other crates — e.g.
565        // loader include errors E9001/E9002 — so this is a subset check, not
566        // strict equality.) Codes are backtick-wrapped in the spec (`**Code:**
567        // `E1001``), so the backtick delimiters keep `E1001` from matching
568        // inside `E10001`.
569        // The spec lives at the workspace root (`spec/core/validation.md`),
570        // OUTSIDE this crate, so it is not packaged to crates.io. Read it at
571        // runtime relative to `CARGO_MANIFEST_DIR` and skip when it is absent —
572        // e.g. `cargo test` on the published crate, which the Nix release channel
573        // runs — rather than `include_str!`-ing it at compile time, which would
574        // fail to build the published crate's tests (broke the Nix release
575        // channel on 0.17.x).
576        let spec_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../spec/core/validation.md");
577        let Ok(spec) = std::fs::read_to_string(spec_path) else {
578            eprintln!(
579                "skipping error_codes_documented_in_spec: {spec_path} not present (published-crate build)"
580            );
581            return;
582        };
583        let missing: Vec<&str> = ErrorCode::ALL
584            .iter()
585            .map(ErrorCode::code)
586            .filter(|code| !spec.contains(&format!("`{code}`")))
587            .collect();
588        assert!(
589            missing.is_empty(),
590            "error codes missing from spec/core/validation.md: {missing:?}"
591        );
592    }
593
594    #[test]
595    fn all_lists_distinct_codes() {
596        // Cheap completeness/dup guard for `ALL`: every code string is unique.
597        let mut codes: Vec<&str> = ErrorCode::ALL.iter().map(ErrorCode::code).collect();
598        let n = codes.len();
599        codes.sort_unstable();
600        codes.dedup();
601        assert_eq!(codes.len(), n, "duplicate code in ErrorCode::ALL");
602    }
603
604    #[test]
605    fn invalid_account_name_is_parse_phase() {
606        // E1005 is a lexical/structural account-name check and must be
607        // reported as a parse-phase diagnostic, matching Python beancount.
608        assert!(ErrorCode::InvalidAccountName.is_parse_phase());
609    }
610
611    #[test]
612    fn other_account_errors_are_validate_phase() {
613        // Lifecycle errors remain semantic (validate-phase) concerns.
614        assert!(!ErrorCode::AccountNotOpen.is_parse_phase());
615        assert!(!ErrorCode::AccountAlreadyOpen.is_parse_phase());
616        assert!(!ErrorCode::AccountClosed.is_parse_phase());
617    }
618
619    #[test]
620    fn non_account_errors_are_validate_phase() {
621        assert!(!ErrorCode::TransactionUnbalanced.is_parse_phase());
622        assert!(!ErrorCode::BalanceAssertionFailed.is_parse_phase());
623        assert!(!ErrorCode::UnknownOption.is_parse_phase());
624    }
625}