Skip to main content

rustledger_parser/
error.rs

1//! Parse error types.
2
3use crate::Span;
4use std::fmt;
5
6/// A parse error with location information.
7///
8/// Marked `#[non_exhaustive]` so external consumers must go through
9/// [`ParseError::new`] and the builder methods rather than constructing
10/// via struct literal. Future fields (e.g., a suggested fix-it span,
11/// related-information back-references) then land as non-breaking
12/// additions. Same `SemVer` hygiene argument as `crate::ParseResult`.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct ParseError {
16    /// The kind of error.
17    pub kind: ParseErrorKind,
18    /// The span where the error occurred.
19    pub span: Span,
20    /// Optional context message.
21    pub context: Option<String>,
22    /// Optional hint for fixing the error.
23    pub hint: Option<String>,
24}
25
26impl ParseError {
27    /// Create a new parse error.
28    #[must_use]
29    pub const fn new(kind: ParseErrorKind, span: Span) -> Self {
30        Self {
31            kind,
32            span,
33            context: None,
34            hint: None,
35        }
36    }
37
38    /// Add context to this error.
39    #[must_use]
40    pub fn with_context(mut self, context: impl Into<String>) -> Self {
41        self.context = Some(context.into());
42        self
43    }
44
45    /// Add a hint for fixing this error.
46    #[must_use]
47    pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
48        self.hint = Some(hint.into());
49        self
50    }
51
52    /// Get the span of this error.
53    #[must_use]
54    pub const fn span(&self) -> (usize, usize) {
55        (self.span.start, self.span.end)
56    }
57
58    /// Get a numeric code for the error kind.
59    #[must_use]
60    pub const fn kind_code(&self) -> u32 {
61        match &self.kind {
62            ParseErrorKind::UnexpectedChar(_) => 1,
63            ParseErrorKind::UnexpectedEof => 2,
64            ParseErrorKind::Expected(_) => 3,
65            ParseErrorKind::InvalidDate(_) => 4,
66            ParseErrorKind::InvalidNumber(_) => 5,
67            ParseErrorKind::InvalidAccount(_) => 6,
68            ParseErrorKind::InvalidCurrency(_) => 7,
69            ParseErrorKind::UnclosedString => 8,
70            ParseErrorKind::InvalidEscape(_) => 9,
71            ParseErrorKind::MissingField(_) => 10,
72            ParseErrorKind::IndentationError => 11,
73            ParseErrorKind::SyntaxError(_) => 12,
74            ParseErrorKind::MissingNewline => 13,
75            ParseErrorKind::MissingAccount => 14,
76            ParseErrorKind::InvalidDateValue(_) => 15,
77            ParseErrorKind::MissingAmount => 16,
78            ParseErrorKind::MissingCurrency => 17,
79            ParseErrorKind::InvalidAccountFormat(_) => 18,
80            ParseErrorKind::MissingDirective => 19,
81            ParseErrorKind::InvalidPoptag(_) => 20,
82            ParseErrorKind::UnclosedPushtag(_) => 21,
83            ParseErrorKind::InvalidPopmeta(_) => 22,
84            ParseErrorKind::UnclosedPushmeta(_) => 23,
85            ParseErrorKind::DeprecatedPipeSymbol => 24,
86            ParseErrorKind::InvalidBookingMethod(_) => 25,
87            ParseErrorKind::BomInDirectiveBody => 26,
88        }
89    }
90
91    /// Get the error message.
92    #[must_use]
93    pub fn message(&self) -> String {
94        format!("{}", self.kind)
95    }
96
97    /// One sample `ParseErrorKind` per variant. Used by cross-crate
98    /// sync tests that need a complete variant set to verify
99    /// schema/test arrays stay in sync with the enum.
100    ///
101    /// **Single source of truth (round-19).** Pre-round-19 this
102    /// function had a `sample()` helper with an exhaustive match
103    /// PLUS a hand-maintained `vec![]` that called sample for each
104    /// variant. Only the `sample()` match was a compile-time gate —
105    /// a contributor adding a variant could update `sample()` and
106    /// forget the vec, leaving the returned list silently
107    /// incomplete (no compile error, no test failure unless a
108    /// downstream sync test specifically required N entries).
109    /// Round-19 collapsed both into this single vec literal. The
110    /// compile-time enforcement now lives in the
111    /// `every_kind_sample_covers_every_variant` test below, whose
112    /// exhaustive `variant_index` match catches a missing variant
113    /// at compile time and a missing vec entry at test time.
114    ///
115    /// Marked `#[doc(hidden)]` because this exists for test
116    /// infrastructure only — the stable public API is `ParseError`
117    /// + `ParseErrorKind` + `kind_code()`; this helper is a
118    /// convenience for integration tests.
119    #[doc(hidden)]
120    #[must_use]
121    pub fn every_kind_sample() -> Vec<ParseErrorKind> {
122        vec![
123            ParseErrorKind::UnexpectedChar('x'),
124            ParseErrorKind::UnexpectedEof,
125            ParseErrorKind::Expected(String::new()),
126            ParseErrorKind::InvalidDate(String::new()),
127            ParseErrorKind::InvalidNumber(String::new()),
128            ParseErrorKind::InvalidAccount(String::new()),
129            ParseErrorKind::InvalidCurrency(String::new()),
130            ParseErrorKind::UnclosedString,
131            ParseErrorKind::InvalidEscape('x'),
132            ParseErrorKind::MissingField(String::new()),
133            ParseErrorKind::IndentationError,
134            ParseErrorKind::SyntaxError(String::new()),
135            ParseErrorKind::MissingNewline,
136            ParseErrorKind::MissingAccount,
137            ParseErrorKind::InvalidDateValue(String::new()),
138            ParseErrorKind::MissingAmount,
139            ParseErrorKind::MissingCurrency,
140            ParseErrorKind::InvalidAccountFormat(String::new()),
141            ParseErrorKind::MissingDirective,
142            ParseErrorKind::InvalidPoptag(String::new()),
143            ParseErrorKind::UnclosedPushtag(String::new()),
144            ParseErrorKind::InvalidPopmeta(String::new()),
145            ParseErrorKind::UnclosedPushmeta(String::new()),
146            ParseErrorKind::DeprecatedPipeSymbol,
147            ParseErrorKind::InvalidBookingMethod(String::new()),
148            ParseErrorKind::BomInDirectiveBody,
149        ]
150    }
151
152    /// Get a short label for the error.
153    #[must_use]
154    pub const fn label(&self) -> &str {
155        match &self.kind {
156            ParseErrorKind::UnexpectedChar(_) => "unexpected character",
157            ParseErrorKind::UnexpectedEof => "unexpected end of file",
158            ParseErrorKind::Expected(_) => "expected different token",
159            ParseErrorKind::InvalidDate(_) => "invalid date",
160            ParseErrorKind::InvalidNumber(_) => "invalid number",
161            ParseErrorKind::InvalidAccount(_) => "invalid account",
162            ParseErrorKind::InvalidCurrency(_) => "invalid currency",
163            ParseErrorKind::UnclosedString => "unclosed string",
164            ParseErrorKind::InvalidEscape(_) => "invalid escape",
165            ParseErrorKind::MissingField(_) => "missing field",
166            ParseErrorKind::IndentationError => "indentation error",
167            ParseErrorKind::SyntaxError(_) => "parse error",
168            ParseErrorKind::MissingNewline => "syntax error",
169            ParseErrorKind::MissingAccount => "expected account name",
170            ParseErrorKind::InvalidDateValue(_) => "invalid date value",
171            ParseErrorKind::MissingAmount => "expected amount",
172            ParseErrorKind::MissingCurrency => "expected currency",
173            ParseErrorKind::InvalidAccountFormat(_) => "invalid account format",
174            ParseErrorKind::MissingDirective => "expected directive",
175            ParseErrorKind::InvalidPoptag(_) => "invalid poptag",
176            ParseErrorKind::UnclosedPushtag(_) => "unclosed pushtag",
177            ParseErrorKind::InvalidPopmeta(_) => "invalid popmeta",
178            ParseErrorKind::UnclosedPushmeta(_) => "unclosed pushmeta",
179            ParseErrorKind::DeprecatedPipeSymbol => "deprecated pipe symbol",
180            ParseErrorKind::InvalidBookingMethod(_) => "invalid booking method",
181            ParseErrorKind::BomInDirectiveBody => "mid-file BOM",
182        }
183    }
184}
185
186impl fmt::Display for ParseError {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        write!(f, "{}", self.kind)?;
189        if let Some(ctx) = &self.context {
190            write!(f, " ({ctx})")?;
191        }
192        Ok(())
193    }
194}
195
196impl std::error::Error for ParseError {}
197
198/// Kinds of parse errors.
199///
200/// Marked `#[non_exhaustive]` because new variants land routinely
201/// (the most recent was `BomInDirectiveBody` — variant 26). Without
202/// the attribute, every new variant would be a `SemVer`-breaking change
203/// for external consumers that `match err.kind { ... }` exhaustively
204/// without a wildcard arm.
205#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum ParseErrorKind {
208    /// Unexpected character in input.
209    UnexpectedChar(char),
210    /// Unexpected end of file.
211    UnexpectedEof,
212    /// Expected a specific token.
213    Expected(String),
214    /// Invalid date format.
215    InvalidDate(String),
216    /// Invalid number format.
217    InvalidNumber(String),
218    /// Invalid account name.
219    InvalidAccount(String),
220    /// Invalid currency code.
221    InvalidCurrency(String),
222    /// Unclosed string literal.
223    UnclosedString,
224    /// Invalid escape sequence in string.
225    InvalidEscape(char),
226    /// Missing required field.
227    MissingField(String),
228    /// Indentation error.
229    IndentationError,
230    /// Generic syntax error.
231    SyntaxError(String),
232    /// Missing final newline.
233    MissingNewline,
234    /// Missing account name (e.g., after 'open' keyword).
235    MissingAccount,
236    /// Invalid date value (e.g., month 13, day 32).
237    InvalidDateValue(String),
238    /// Missing amount in posting.
239    MissingAmount,
240    /// Missing currency after number.
241    MissingCurrency,
242    /// Invalid account format (e.g., missing colon).
243    InvalidAccountFormat(String),
244    /// Missing directive after date.
245    MissingDirective,
246    /// Poptag for a tag that was never pushed.
247    InvalidPoptag(String),
248    /// Pushtag that was never popped (unclosed).
249    UnclosedPushtag(String),
250    /// Popmeta for a key that was never pushed.
251    InvalidPopmeta(String),
252    /// Pushmeta that was never popped (unclosed).
253    UnclosedPushmeta(String),
254    /// Deprecated pipe symbol in transaction.
255    DeprecatedPipeSymbol,
256    /// Invalid booking method (must be uppercase: FIFO, STRICT, `STRICT_WITH_SIZE`, LIFO, HIFO, NONE, AVERAGE).
257    InvalidBookingMethod(String),
258    /// UTF-8 byte-order mark detected in a directive body (mid-file
259    /// BOM that survived the leading-BOM strip at the parser's
260    /// public entry — typically a concatenation accident or an
261    /// embedded-BOM payload).
262    ///
263    /// A dedicated variant rather than `SyntaxError(String)` so that:
264    ///
265    /// 1. The diagnostic text isn't re-stringified at every emission
266    ///    site (Display renders the static message from a `&'static
267    ///    str`, so no `to_string()` heap allocation per error).
268    /// 2. External consumers get a structural discriminant to match
269    ///    on instead of a brittle string-equality compare against a
270    ///    message body that might be reworded.
271    /// 3. The message text becomes an implementation detail (it
272    ///    lives in this enum's Display impl) rather than a public
273    ///    `&'static str` constant whose wording would be a `SemVer`
274    ///    stability commitment.
275    BomInDirectiveBody,
276}
277
278impl fmt::Display for ParseErrorKind {
279    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
280        match self {
281            Self::UnexpectedChar(c) => write!(f, "syntax error: unexpected '{c}'"),
282            Self::UnexpectedEof => write!(f, "unexpected end of file"),
283            Self::Expected(what) => write!(f, "expected {what}"),
284            Self::InvalidDate(s) => write!(f, "invalid date '{s}'"),
285            Self::InvalidNumber(s) => write!(f, "invalid number '{s}'"),
286            Self::InvalidAccount(s) => write!(f, "Invalid account '{s}'"),
287            Self::InvalidCurrency(s) => write!(f, "invalid currency '{s}'"),
288            Self::UnclosedString => write!(f, "unclosed string literal"),
289            Self::InvalidEscape(c) => write!(f, "invalid escape sequence '\\{c}'"),
290            Self::MissingField(field) => write!(f, "missing required field: {field}"),
291            Self::IndentationError => write!(f, "indentation error"),
292            Self::SyntaxError(msg) => write!(f, "parse error: {msg}"),
293            Self::MissingNewline => write!(f, "syntax error: missing final newline"),
294            Self::MissingAccount => write!(f, "expected account name"),
295            Self::InvalidDateValue(msg) => write!(f, "invalid date: {msg}"),
296            Self::MissingAmount => write!(f, "expected amount in posting"),
297            Self::MissingCurrency => write!(f, "expected currency after number"),
298            Self::InvalidAccountFormat(s) => {
299                write!(f, "invalid account '{s}': must contain ':'")
300            }
301            Self::MissingDirective => write!(f, "expected directive after date"),
302            Self::InvalidPoptag(tag) => {
303                write!(f, "poptag attempted on tag '{tag}' which was never pushed")
304            }
305            Self::UnclosedPushtag(tag) => {
306                write!(f, "pushtag '{tag}' was never popped")
307            }
308            Self::InvalidPopmeta(key) => {
309                write!(f, "popmeta attempted on key '{key}' which was never pushed")
310            }
311            Self::UnclosedPushmeta(key) => {
312                write!(f, "pushmeta '{key}' was never popped")
313            }
314            Self::DeprecatedPipeSymbol => {
315                write!(f, "Pipe symbol is deprecated")
316            }
317            Self::InvalidBookingMethod(m) => {
318                write!(
319                    f,
320                    "invalid booking method '{m}': must be one of FIFO, STRICT, STRICT_WITH_SIZE, LIFO, HIFO, NONE, AVERAGE"
321                )
322            }
323            Self::BomInDirectiveBody => f.write_str(BOM_MIDFILE_DIAGNOSTIC),
324        }
325    }
326}
327
328/// Mid-file BOM diagnostic message.
329///
330/// Private — emission sites construct `ParseErrorKind::BomInDirectiveBody`
331/// and the Display impl renders this text. External consumers detect
332/// the diagnostic structurally via the enum variant, not by string
333/// compare against this constant. Reworking the wording is therefore
334/// not a `SemVer` break.
335///
336/// Spelled via `concat!` rather than a `\<newline>` line-continuation
337/// literal so editor 'strip trailing whitespace on save' and similar
338/// hooks can't collapse word boundaries inside the string.
339const BOM_MIDFILE_DIAGNOSTIC: &str = concat!(
340    "Invalid token: UTF-8 BOM detected in directive body ",
341    "(only a leading BOM is permitted); ",
342    "did you concatenate two BOM-prefixed files or paste content with an embedded BOM?",
343);
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    /// Compile-time + test-time gate for `every_kind_sample`'s
350    /// completeness. The `variant_index` match is exhaustive over
351    /// `ParseErrorKind` — adding a new variant breaks compilation
352    /// until an arm is added with a unique index. The runtime
353    /// assertion below then catches the case where a contributor
354    /// added the variant + the `variant_index` arm but forgot to
355    /// add a constructor to `every_kind_sample`'s vec.
356    ///
357    /// Two compile gates working together:
358    ///   1. `variant_index` match exhaustiveness — catches added
359    ///      variants at compile time.
360    ///   2. The unique-index + max-index assertions — catches a
361    ///      missing or duplicate `every_kind_sample` vec entry at
362    ///      test time.
363    #[test]
364    fn every_kind_sample_covers_every_variant() {
365        // Assign each variant a unique sequential index. The match
366        // is exhaustive — a new variant breaks compile until an
367        // arm is added with the next index.
368        fn variant_index(k: &ParseErrorKind) -> u32 {
369            match k {
370                ParseErrorKind::UnexpectedChar(_) => 0,
371                ParseErrorKind::UnexpectedEof => 1,
372                ParseErrorKind::Expected(_) => 2,
373                ParseErrorKind::InvalidDate(_) => 3,
374                ParseErrorKind::InvalidNumber(_) => 4,
375                ParseErrorKind::InvalidAccount(_) => 5,
376                ParseErrorKind::InvalidCurrency(_) => 6,
377                ParseErrorKind::UnclosedString => 7,
378                ParseErrorKind::InvalidEscape(_) => 8,
379                ParseErrorKind::MissingField(_) => 9,
380                ParseErrorKind::IndentationError => 10,
381                ParseErrorKind::SyntaxError(_) => 11,
382                ParseErrorKind::MissingNewline => 12,
383                ParseErrorKind::MissingAccount => 13,
384                ParseErrorKind::InvalidDateValue(_) => 14,
385                ParseErrorKind::MissingAmount => 15,
386                ParseErrorKind::MissingCurrency => 16,
387                ParseErrorKind::InvalidAccountFormat(_) => 17,
388                ParseErrorKind::MissingDirective => 18,
389                ParseErrorKind::InvalidPoptag(_) => 19,
390                ParseErrorKind::UnclosedPushtag(_) => 20,
391                ParseErrorKind::InvalidPopmeta(_) => 21,
392                ParseErrorKind::UnclosedPushmeta(_) => 22,
393                ParseErrorKind::DeprecatedPipeSymbol => 23,
394                ParseErrorKind::InvalidBookingMethod(_) => 24,
395                ParseErrorKind::BomInDirectiveBody => 25,
396            }
397        }
398        let samples = ParseError::every_kind_sample();
399        let indices: std::collections::BTreeSet<u32> = samples.iter().map(variant_index).collect();
400        assert_eq!(
401            indices.len(),
402            samples.len(),
403            "every_kind_sample has duplicate variants (collapsed by variant_index): \
404             samples = {samples:?}, unique indices = {indices:?}"
405        );
406        let max = indices.iter().max().copied().unwrap_or(0);
407        assert_eq!(
408            samples.len() as u32,
409            max + 1,
410            "every_kind_sample is missing variants: highest variant_index = {max}, \
411             expected {} entries in the vec, got {}. Add the missing constructor \
412             to every_kind_sample's vec.",
413            max + 1,
414            samples.len()
415        );
416    }
417
418    #[test]
419    fn test_parse_error_new() {
420        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5));
421        assert_eq!(err.span(), (0, 5));
422        assert!(err.context.is_none());
423        assert!(err.hint.is_none());
424    }
425
426    #[test]
427    fn test_parse_error_with_context() {
428        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
429            .with_context("in transaction");
430        assert_eq!(err.context, Some("in transaction".to_string()));
431    }
432
433    #[test]
434    fn test_parse_error_with_hint() {
435        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
436            .with_hint("add more input");
437        assert_eq!(err.hint, Some("add more input".to_string()));
438    }
439
440    #[test]
441    fn test_parse_error_display_with_context() {
442        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 5))
443            .with_context("parsing header");
444        let display = format!("{err}");
445        assert!(display.contains("unexpected end of file"));
446        assert!(display.contains("parsing header"));
447    }
448
449    #[test]
450    fn test_kind_codes() {
451        // Test all error codes are unique and in expected range
452        let kinds = [
453            (ParseErrorKind::UnexpectedChar('x'), 1),
454            (ParseErrorKind::UnexpectedEof, 2),
455            (ParseErrorKind::Expected("foo".to_string()), 3),
456            (ParseErrorKind::InvalidDate("bad".to_string()), 4),
457            (ParseErrorKind::InvalidNumber("nan".to_string()), 5),
458            (ParseErrorKind::InvalidAccount("bad".to_string()), 6),
459            (ParseErrorKind::InvalidCurrency("???".to_string()), 7),
460            (ParseErrorKind::UnclosedString, 8),
461            (ParseErrorKind::InvalidEscape('n'), 9),
462            (ParseErrorKind::MissingField("name".to_string()), 10),
463            (ParseErrorKind::IndentationError, 11),
464            (ParseErrorKind::SyntaxError("oops".to_string()), 12),
465            (ParseErrorKind::MissingNewline, 13),
466            (ParseErrorKind::MissingAccount, 14),
467            (ParseErrorKind::InvalidDateValue("month 13".to_string()), 15),
468            (ParseErrorKind::MissingAmount, 16),
469            (ParseErrorKind::MissingCurrency, 17),
470            (
471                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
472                18,
473            ),
474            (ParseErrorKind::MissingDirective, 19),
475            (ParseErrorKind::InvalidPoptag("bad".to_string()), 20),
476            (ParseErrorKind::UnclosedPushtag("tag".to_string()), 21),
477            (ParseErrorKind::InvalidPopmeta("key".to_string()), 22),
478            (ParseErrorKind::UnclosedPushmeta("key".to_string()), 23),
479            (ParseErrorKind::DeprecatedPipeSymbol, 24),
480            (ParseErrorKind::InvalidBookingMethod("BAD".to_string()), 25),
481            (ParseErrorKind::BomInDirectiveBody, 26),
482        ];
483
484        for (kind, expected_code) in kinds {
485            let err = ParseError::new(kind, Span::new(0, 1));
486            assert_eq!(err.kind_code(), expected_code);
487        }
488    }
489
490    #[test]
491    fn test_error_labels() {
492        // Test that all error kinds have non-empty labels
493        let kinds = [
494            ParseErrorKind::UnexpectedChar('x'),
495            ParseErrorKind::UnexpectedEof,
496            ParseErrorKind::Expected("foo".to_string()),
497            ParseErrorKind::InvalidDate("bad".to_string()),
498            ParseErrorKind::InvalidNumber("nan".to_string()),
499            ParseErrorKind::InvalidAccount("bad".to_string()),
500            ParseErrorKind::InvalidCurrency("???".to_string()),
501            ParseErrorKind::UnclosedString,
502            ParseErrorKind::InvalidEscape('n'),
503            ParseErrorKind::MissingField("name".to_string()),
504            ParseErrorKind::IndentationError,
505            ParseErrorKind::SyntaxError("oops".to_string()),
506            ParseErrorKind::MissingNewline,
507            ParseErrorKind::MissingAccount,
508            ParseErrorKind::InvalidDateValue("month 13".to_string()),
509            ParseErrorKind::MissingAmount,
510            ParseErrorKind::MissingCurrency,
511            ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
512            ParseErrorKind::MissingDirective,
513            ParseErrorKind::InvalidPoptag("bad".to_string()),
514            ParseErrorKind::UnclosedPushtag("tag".to_string()),
515            ParseErrorKind::InvalidPopmeta("key".to_string()),
516            ParseErrorKind::UnclosedPushmeta("key".to_string()),
517            ParseErrorKind::DeprecatedPipeSymbol,
518            ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
519            ParseErrorKind::BomInDirectiveBody,
520        ];
521
522        for kind in kinds {
523            let err = ParseError::new(kind, Span::new(0, 1));
524            assert!(!err.label().is_empty());
525        }
526    }
527
528    #[test]
529    fn test_error_messages() {
530        // Test Display for all error kinds
531        let test_cases = [
532            (ParseErrorKind::UnexpectedChar('$'), "unexpected '$'"),
533            (ParseErrorKind::UnexpectedEof, "unexpected end of file"),
534            (
535                ParseErrorKind::Expected("number".to_string()),
536                "expected number",
537            ),
538            (
539                ParseErrorKind::InvalidDate("2024-13-01".to_string()),
540                "invalid date '2024-13-01'",
541            ),
542            (
543                ParseErrorKind::InvalidNumber("abc".to_string()),
544                "invalid number 'abc'",
545            ),
546            (
547                ParseErrorKind::InvalidAccount("bad".to_string()),
548                "Invalid account 'bad'",
549            ),
550            (
551                ParseErrorKind::InvalidCurrency("???".to_string()),
552                "invalid currency '???'",
553            ),
554            (ParseErrorKind::UnclosedString, "unclosed string literal"),
555            (
556                ParseErrorKind::InvalidEscape('x'),
557                "invalid escape sequence '\\x'",
558            ),
559            (
560                ParseErrorKind::MissingField("date".to_string()),
561                "missing required field: date",
562            ),
563            (ParseErrorKind::IndentationError, "indentation error"),
564            (
565                ParseErrorKind::SyntaxError("bad token".to_string()),
566                "parse error: bad token",
567            ),
568            (ParseErrorKind::MissingNewline, "missing final newline"),
569            (ParseErrorKind::MissingAccount, "expected account name"),
570            (
571                ParseErrorKind::InvalidDateValue("month 13".to_string()),
572                "invalid date: month 13",
573            ),
574            (ParseErrorKind::MissingAmount, "expected amount in posting"),
575            (
576                ParseErrorKind::MissingCurrency,
577                "expected currency after number",
578            ),
579            (
580                ParseErrorKind::InvalidAccountFormat("Assets".to_string()),
581                "must contain ':'",
582            ),
583            (
584                ParseErrorKind::MissingDirective,
585                "expected directive after date",
586            ),
587            (
588                ParseErrorKind::InvalidPoptag("bad".to_string()),
589                "poptag attempted on tag 'bad'",
590            ),
591            (
592                ParseErrorKind::UnclosedPushtag("tag".to_string()),
593                "pushtag 'tag' was never popped",
594            ),
595            (
596                ParseErrorKind::InvalidPopmeta("key".to_string()),
597                "popmeta attempted on key 'key'",
598            ),
599            (
600                ParseErrorKind::UnclosedPushmeta("key".to_string()),
601                "pushmeta 'key' was never popped",
602            ),
603            (
604                ParseErrorKind::DeprecatedPipeSymbol,
605                "Pipe symbol is deprecated",
606            ),
607            (
608                ParseErrorKind::InvalidBookingMethod("BAD".to_string()),
609                "invalid booking method 'BAD'",
610            ),
611            // BOM diagnostic — Display renders BOM_MIDFILE_DIAGNOSTIC.
612            // Assert on the salient substrings rather than the full text
613            // so a future copyedit of the message body doesn't have to
614            // touch this test, but a regression that drops the BOM
615            // mention entirely (e.g., Display returning "") would fail.
616            (
617                ParseErrorKind::BomInDirectiveBody,
618                "UTF-8 BOM detected in directive body",
619            ),
620        ];
621
622        for (kind, expected_substring) in test_cases {
623            let msg = format!("{kind}");
624            assert!(
625                msg.contains(expected_substring),
626                "Expected '{expected_substring}' in '{msg}'"
627            );
628        }
629    }
630
631    #[test]
632    fn test_parse_error_is_error_trait() {
633        let err = ParseError::new(ParseErrorKind::UnexpectedEof, Span::new(0, 1));
634        // Verify it implements std::error::Error
635        let _: &dyn std::error::Error = &err;
636    }
637}