Skip to main content

mcproto_codec/
error.rs

1//! Errors reported by Minecraft protocol codecs.
2//!
3//! This module provides structured context for failures while reading and
4//! writing the protocol values implemented by `mcproto-codec`.
5
6use std::{error::Error, fmt, io};
7
8type BoxedError = Box<dyn Error + Send + Sync + 'static>;
9
10/// Identifies the protocol codec that reported an error.
11///
12/// A [`CodecError`] stores the codec that originally reported the error and may
13/// also store enclosing codecs as additional context. Protocol descriptions are
14/// based on the [Minecraft Java Edition protocol packet format].
15///
16/// Signed integer codecs use [two's-complement] representation.
17///
18/// [Minecraft Java Edition protocol packet format]: https://minecraft.wiki/w/Java_Edition_protocol/Packets
19/// [two's-complement]: https://en.wikipedia.org/wiki/Two%27s_complement
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum CodecKind {
23    /// A variable-length, two's-complement signed 32-bit integer.
24    ///
25    /// Values range from -2,147,483,648 through 2,147,483,647.
26    VarInt,
27    /// A variable-length, two's-complement signed 64-bit integer.
28    ///
29    /// Values range from -9,223,372,036,854,775,808 through
30    /// 9,223,372,036,854,775,807.
31    VarLong,
32    /// A complete Named Binary Tag value.
33    ///
34    /// The value is encoded and decoded using `fastnbt`.
35    Nbt,
36    /// A boolean encoded as `0x00` for false or `0x01` for true.
37    Boolean,
38    /// A two's-complement signed 8-bit integer from -128 through 127.
39    Byte,
40    /// An unsigned 8-bit integer from 0 through 255.
41    UnsignedByte,
42    /// A two's-complement signed 16-bit integer from -32,768 through 32,767.
43    Short,
44    /// An unsigned 16-bit integer from 0 through 65,535.
45    UnsignedShort,
46    /// A two's-complement signed 32-bit integer from -2,147,483,648 through
47    /// 2,147,483,647.
48    Int,
49    /// A two's-complement signed 64-bit integer from -9,223,372,036,854,775,808
50    /// through 9,223,372,036,854,775,807.
51    Long,
52    /// A block position packed into a 64-bit integer.
53    ///
54    /// The x, z, and y coordinates occupy 26, 26, and 12 bits respectively.
55    Position,
56    /// A rotation angle encoded in 1/256 turn steps.
57    Angle,
58    /// A 128-bit universally unique identifier.
59    Uuid,
60    /// A length-prefixed bit set of packed 64-bit words.
61    BitSet,
62    /// A fixed-length bit set of packed bytes.
63    FixedBitSet,
64    /// A UTF-8 string prefixed by its byte length as a VarInt.
65    ///
66    /// The protocol limits both the UTF-8 payload size and the number of UTF-16
67    /// code units. Supplementary [Unicode scalar values] count as two UTF-16
68    /// code units. The general protocol limit is 32,767 UTF-16 code units and
69    /// three UTF-8 bytes per permitted code unit; a particular field may impose
70    /// a lower limit.
71    ///
72    /// [Unicode scalar values]: https://www.unicode.org/glossary/#unicode_scalar_value
73    String,
74    /// A resource identifier encoded as a [`String`](Self::String).
75    ///
76    /// The namespace permits `[a-z0-9._-]`; the value permits
77    /// `[a-z0-9._/-]`. See the protocol's [identifier format] for details.
78    ///
79    /// [identifier format]: https://minecraft.wiki/w/Java_Edition_protocol/Packets#Identifier
80    Identifier,
81    /// A text component encoded as an NBT tag.
82    ///
83    /// Plain text-only components may use an NBT string tag. Components with
84    /// styling, events, or other data use an NBT compound tag. See the
85    /// [text component format] and [NBT specification].
86    ///
87    /// [text component format]: https://minecraft.wiki/w/Text_component_format
88    /// [NBT specification]: https://minecraft.wiki/w/NBT_format
89    TextComponent,
90    /// A text component encoded as JSON in a protocol string.
91    ///
92    /// Since Java Edition 1.20.3, the vanilla implementation permits up to
93    /// 262,144 UTF-16 code units when decoding but refuses to encode more than
94    /// 32,767. See the [text component format].
95    ///
96    /// [text component format]: https://minecraft.wiki/w/Text_component_format
97    JsonTextComponent,
98}
99
100/// Formats a codec kind using its protocol name, such as `VarInt` or `Boolean`.
101impl fmt::Display for CodecKind {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::VarInt => formatter.write_str("VarInt"),
105            Self::VarLong => formatter.write_str("VarLong"),
106            Self::Nbt => formatter.write_str("Nbt"),
107            Self::Boolean => formatter.write_str("Boolean"),
108            Self::Byte => formatter.write_str("Byte"),
109            Self::UnsignedByte => formatter.write_str("UnsignedByte"),
110            Self::Short => formatter.write_str("Short"),
111            Self::UnsignedShort => formatter.write_str("UnsignedShort"),
112            Self::Int => formatter.write_str("Int"),
113            Self::Long => formatter.write_str("Long"),
114            Self::Position => formatter.write_str("Position"),
115            Self::Angle => formatter.write_str("Angle"),
116            Self::Uuid => formatter.write_str("UUID"),
117            Self::BitSet => formatter.write_str("BitSet"),
118            Self::FixedBitSet => formatter.write_str("Fixed BitSet"),
119            Self::String => formatter.write_str("String"),
120            Self::Identifier => formatter.write_str("Identifier"),
121            Self::TextComponent => formatter.write_str("TextComponent"),
122            Self::JsonTextComponent => formatter.write_str("JsonTextComponent"),
123        }
124    }
125}
126
127/// Identifies whether an error occurred while decoding or encoding data.
128#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
129#[non_exhaustive]
130pub enum CodecOperation {
131    /// A read (decoding) operation.
132    Read,
133    /// A write (encoding) operation.
134    Write,
135}
136
137/// Formats an operation as `reading` or `writing`.
138impl fmt::Display for CodecOperation {
139    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140        match self {
141            Self::Read => formatter.write_str("reading"),
142            Self::Write => formatter.write_str("writing"),
143        }
144    }
145}
146/// Describes why encoded protocol data is invalid.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
148#[non_exhaustive]
149pub enum InvalidEncodingReason {
150    /// The encoding exceeds the maximum allowed length in bytes.
151    TooLong {
152        /// The maximum number of bytes permitted for this encoding.
153        max_bytes: usize,
154    },
155    /// The terminal byte of the encoding contains bits outside the allowed mask.
156    ValueOutOfRange {
157        /// The final byte that contains disallowed bits.
158        terminal_byte: u8,
159        /// A mask whose set bits identify the permitted bits in the final byte.
160        allowed_mask: u8,
161    },
162    /// The boolean value is invalid (not 0x00 or 0x01).
163    InvalidBooleanValue {
164        /// The byte read instead of the permitted `0x00` or `0x01`.
165        value: u8,
166    },
167    /// The string exceeds the maximum allowed length in bytes when encoded in UTF-8.
168    StringTooLong {
169        /// The maximum permitted size of the UTF-8 payload, excluding its
170        /// VarInt length prefix.
171        max_bytes: usize,
172    },
173    /// The string exceeds the maximum allowed length in UTF-16 code units.
174    TooManyUtf16CodeUnits {
175        /// The maximum permitted number of UTF-16 code units.
176        max_code_units: usize,
177    },
178    /// The length of the data is negative, which is invalid.
179    NegativeLength {
180        /// The negative length decoded from the data.
181        value: i32,
182    },
183    /// The packed byte array does not have the required fixed length.
184    InvalidFixedBitSetLength {
185        /// The expected number of packed bytes.
186        expected: usize,
187        /// The actual number of packed bytes.
188        actual: usize,
189    },
190    /// The data contains an invalid UTF-8 sequence.
191    InvalidUtf8 {
192        /// The byte offset in the UTF-8 payload up to which the data is valid.
193        valid_up_to: usize,
194        /// The length of the invalid sequence, or `None` if the input ends in
195        /// an incomplete sequence.
196        error_len: Option<usize>,
197    },
198    /// The data is not a valid Minecraft identifier.
199    InvalidIdentifier,
200    /// The data is not valid NBT (Named Binary Tag) data.
201    InvalidNbt,
202    /// The data is not valid JSON.
203    InvalidJson,
204    /// The root tag of a text component is invalid (not TAG_String or TAG_Compound).
205    InvalidTextComponentRootTag {
206        /// The unsupported NBT root tag identifier.
207        tag: u8,
208    },
209}
210/// Formats an invalid encoding reason as a diagnostic message.
211impl fmt::Display for InvalidEncodingReason {
212    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
213        match self {
214            Self::TooLong { max_bytes } => {
215                write!(formatter, "encoding exceeds the {max_bytes}-byte limit")
216            }
217            Self::ValueOutOfRange {
218                terminal_byte,
219                allowed_mask,
220            } => write!(
221                formatter,
222                "terminal byte 0x{terminal_byte:02X} contains bits outside mask 0x{allowed_mask:02X}"
223            ),
224            Self::InvalidBooleanValue { value } => {
225                write!(formatter, "invalid boolean value 0x{value:02X}")
226            }
227            Self::StringTooLong { max_bytes } => {
228                write!(formatter, "string exceeds the {max_bytes}-byte UTF-8 limit")
229            }
230            Self::TooManyUtf16CodeUnits { max_code_units } => write!(
231                formatter,
232                "string exceeds the {max_code_units}-code-unit UTF-16 limit"
233            ),
234            Self::NegativeLength { value } => {
235                write!(formatter, "length cannot be negative: {value}")
236            }
237            Self::InvalidFixedBitSetLength { expected, actual } => write!(
238                formatter,
239                "fixed bit set requires {expected} packed bytes, got {actual}"
240            ),
241            Self::InvalidUtf8 {
242                valid_up_to,
243                error_len: Some(error_len),
244            } => write!(
245                formatter,
246                "invalid UTF-8 sequence of {error_len} bytes at byte {valid_up_to}"
247            ),
248            Self::InvalidUtf8 {
249                valid_up_to,
250                error_len: None,
251            } => write!(
252                formatter,
253                "incomplete UTF-8 sequence starting at byte {valid_up_to}"
254            ),
255            Self::InvalidIdentifier => formatter.write_str("invalid Minecraft identifier"),
256            Self::InvalidNbt => formatter.write_str("invalid NBT data"),
257            Self::InvalidJson => formatter.write_str("invalid JSON data"),
258            Self::InvalidTextComponentRootTag { tag } => write!(
259                formatter,
260                "text component root tag must be TAG_String (8) or TAG_Compound (10), got {tag}"
261            ),
262        }
263    }
264}
265/// Classifies an error reported by a protocol codec.
266#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
267#[non_exhaustive]
268pub enum CodecErrorKind {
269    /// An I/O error other than an unexpected end of input occurred.
270    Io,
271    /// A read ended before the codec received all required bytes.
272    UnexpectedEof,
273    /// The data could not be decoded or encoded according to the codec's
274    /// format or limits.
275    InvalidEncoding(InvalidEncodingReason),
276}
277
278/// An error produced while reading or writing protocol data.
279///
280/// The error records the originating [`CodecKind`], the [`CodecOperation`], the
281/// progress within that codec, and optional enclosing codec contexts. I/O and
282/// parser errors are retained as an error [`source`](Error::source).
283///
284/// Error enums are non-exhaustive, so downstream matches must include a
285/// wildcard arm.
286///
287/// # Example
288///
289/// ```
290/// use mcproto_codec::{
291///     error::{CodecErrorKind, CodecKind, CodecOperation},
292///     varint::VarIntRead,
293/// };
294///
295/// let mut input = [0x80].as_slice();
296/// let error = input
297///     .read_varint()
298///     .unwrap_err()
299///     .with_context(CodecKind::String);
300///
301/// assert_eq!(error.codec(), CodecKind::VarInt);
302/// assert_eq!(error.operation(), CodecOperation::Read);
303/// assert_eq!(error.bytes_processed(), 1);
304/// assert_eq!(error.contexts(), &[CodecKind::String]);
305///
306/// match error.kind() {
307///     CodecErrorKind::UnexpectedEof => {}
308///     _ => panic!("unexpected error: {error}"),
309/// }
310/// ```
311#[derive(Debug)]
312pub struct CodecError {
313    /// The error classification.
314    ///
315    /// This field and [`kind`](Self::kind) expose the same value. The accessor
316    /// is convenient when working through a shared reference.
317    pub kind: CodecErrorKind,
318    codec: CodecKind,
319    contexts: Contexts,
320    operation: CodecOperation,
321    bytes_processed: usize,
322    source: Option<BoxedError>,
323}
324
325/// Stores the enclosing codec contexts of a [`CodecError`].
326///
327/// The common cases of zero or one context are stored without heap allocation;
328/// only longer chains fall back to a [`Vec`].
329#[derive(Debug, Default)]
330enum Contexts {
331    /// No enclosing contexts.
332    #[default]
333    None,
334    /// A single context, stored inline.
335    One(CodecKind),
336    /// Two or more contexts, stored in a heap-allocated vector.
337    Many(Vec<CodecKind>),
338}
339
340impl CodecError {
341    /// Returns the error classification.
342    pub const fn kind(&self) -> CodecErrorKind {
343        self.kind
344    }
345    /// Returns the codec that originally reported the error.
346    pub const fn codec(&self) -> CodecKind {
347        self.codec
348    }
349    /// Returns the outermost enclosing codec context, if one was added.
350    ///
351    /// This is the last element of [`contexts`](Self::contexts), not the
352    /// originating codec returned by [`codec`](Self::codec).
353    pub fn context(&self) -> Option<CodecKind> {
354        self.contexts().last().copied()
355    }
356    /// Returns all enclosing codec contexts, ordered from nearest to outermost.
357    ///
358    /// The originating codec is not included. Each call to
359    /// [`with_context`](Self::with_context) appends one element.
360    pub fn contexts(&self) -> &[CodecKind] {
361        match &self.contexts {
362            Contexts::None => &[],
363            Contexts::One(context) => std::slice::from_ref(context),
364            Contexts::Many(contexts) => contexts,
365        }
366    }
367    /// Returns the operation being performed when the error occurred.
368    pub const fn operation(&self) -> CodecOperation {
369        self.operation
370    }
371    /// Returns the byte progress reported by the originating codec.
372    ///
373    /// Built-in codecs count bytes from the start of their encoded value. Bytes
374    /// successfully read or written before an I/O failure are included. A byte
375    /// that was read and then found to be invalid is also included. For a
376    /// length-prefixed value, the originating codec determines whether its
377    /// prefix is part of the count.
378    ///
379    /// Adding an outer context does not translate this value into an offset
380    /// within the enclosing codec.
381    pub const fn bytes_processed(&self) -> usize {
382        self.bytes_processed
383    }
384    /// Returns the underlying [`io::Error`], if the source is an I/O error.
385    ///
386    /// Invalid NBT or JSON errors may have a non-I/O source; access those
387    /// through [`Error::source`] instead.
388    pub fn io_error(&self) -> Option<&io::Error> {
389        self.source.as_deref()?.downcast_ref::<io::Error>()
390    }
391
392    /// Adds an enclosing codec to the error's context chain.
393    ///
394    /// Contexts should be added as the error propagates outward. Repeated calls
395    /// therefore order [`contexts`](Self::contexts) from nearest to outermost,
396    /// and [`context`](Self::context) returns the most recently added context.
397    pub fn with_context(mut self, context: CodecKind) -> Self {
398        self.contexts = match self.contexts {
399            Contexts::None => Contexts::One(context),
400            Contexts::One(first) => Contexts::Many(vec![first, context]),
401            Contexts::Many(mut contexts) => {
402                contexts.push(context);
403                Contexts::Many(contexts)
404            }
405        };
406        self
407    }
408    /// Creates an error from an I/O failure that occurred while reading.
409    ///
410    /// [`io::ErrorKind::UnexpectedEof`] maps to
411    /// [`CodecErrorKind::UnexpectedEof`]; every other error kind maps to
412    /// [`CodecErrorKind::Io`]. The source error is retained.
413    ///
414    /// `bytes_processed` is the number of bytes read before `source` occurred.
415    pub fn from_read_error(codec: CodecKind, bytes_processed: usize, source: io::Error) -> Self {
416        let kind = if source.kind() == io::ErrorKind::UnexpectedEof {
417            CodecErrorKind::UnexpectedEof
418        } else {
419            CodecErrorKind::Io
420        };
421
422        Self {
423            kind,
424            codec,
425            contexts: Contexts::None,
426            operation: CodecOperation::Read,
427            bytes_processed,
428            source: Some(Box::new(source)),
429        }
430    }
431    /// Creates an error from an I/O failure that occurred while writing.
432    ///
433    /// All write errors map to [`CodecErrorKind::Io`], and the source error is
434    /// retained. `bytes_processed` is the number of bytes written before
435    /// `source` occurred.
436    pub fn from_write_error(codec: CodecKind, bytes_processed: usize, source: io::Error) -> Self {
437        Self {
438            kind: CodecErrorKind::Io,
439            codec,
440            contexts: Contexts::None,
441            operation: CodecOperation::Write,
442            bytes_processed,
443            source: Some(Box::new(source)),
444        }
445    }
446    /// Creates an invalid encoding error for a read operation.
447    ///
448    /// Use [`invalid_encoding_for_operation`](Self::invalid_encoding_for_operation)
449    /// when the operation is not necessarily [`CodecOperation::Read`].
450    pub const fn invalid_encoding(
451        codec: CodecKind,
452        bytes_processed: usize,
453        reason: InvalidEncodingReason,
454    ) -> Self {
455        Self::invalid_encoding_for_operation(codec, CodecOperation::Read, bytes_processed, reason)
456    }
457
458    /// Creates an invalid encoding error for the specified operation.
459    ///
460    /// Unlike [`invalid_encoding`](Self::invalid_encoding), this constructor
461    /// does not assume that the error occurred while reading.
462    pub const fn invalid_encoding_for_operation(
463        codec: CodecKind,
464        operation: CodecOperation,
465        bytes_processed: usize,
466        reason: InvalidEncodingReason,
467    ) -> Self {
468        Self {
469            kind: CodecErrorKind::InvalidEncoding(reason),
470            codec,
471            contexts: Contexts::None,
472            operation,
473            bytes_processed,
474            source: None,
475        }
476    }
477    /// Creates an invalid encoding error with an underlying source error.
478    ///
479    /// `operation` may be either reading or writing. The supplied error is
480    /// available through [`Error::source`]; if it is an [`io::Error`], it is
481    /// also available through [`io_error`](Self::io_error).
482    pub fn invalid_encoding_for_operation_with_source(
483        codec: CodecKind,
484        operation: CodecOperation,
485        bytes_processed: usize,
486        reason: InvalidEncodingReason,
487        source: impl Error + Send + Sync + 'static,
488    ) -> Self {
489        Self {
490            kind: CodecErrorKind::InvalidEncoding(reason),
491            codec,
492            contexts: Contexts::None,
493            operation,
494            bytes_processed,
495            source: Some(Box::new(source)),
496        }
497    }
498}
499
500impl fmt::Display for CodecError {
501    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
502        match self.kind {
503            CodecErrorKind::Io => write!(
504                formatter,
505                "I/O error while {} {} after {} bytes",
506                self.operation, self.codec, self.bytes_processed
507            )?,
508            CodecErrorKind::UnexpectedEof => write!(
509                formatter,
510                "unexpected end of input while reading {} after {} bytes",
511                self.codec, self.bytes_processed
512            )?,
513            CodecErrorKind::InvalidEncoding(reason) => write!(
514                formatter,
515                "invalid {} encoding after {} bytes: {reason}",
516                self.codec, self.bytes_processed
517            )?,
518        }
519
520        for context in self.contexts() {
521            write!(formatter, " while processing {context}")?;
522        }
523
524        if let Some(source) = &self.source {
525            write!(formatter, ": {source}")?;
526        }
527
528        Ok(())
529    }
530}
531
532impl Error for CodecError {
533    fn source(&self) -> Option<&(dyn Error + 'static)> {
534        self.source
535            .as_deref()
536            .map(|source| source as &(dyn Error + 'static))
537    }
538}
539
540#[cfg(test)]
541mod tests {
542    use super::*;
543
544    fn read_error() -> CodecError {
545        CodecError::from_read_error(
546            CodecKind::VarInt,
547            3,
548            io::Error::new(io::ErrorKind::UnexpectedEof, "stream ended"),
549        )
550    }
551
552    fn write_error() -> CodecError {
553        CodecError::from_write_error(CodecKind::String, 5, io::Error::other("disk full"))
554    }
555
556    fn invalid_encoding_error() -> CodecError {
557        CodecError::invalid_encoding_for_operation(
558            CodecKind::Boolean,
559            CodecOperation::Read,
560            1,
561            InvalidEncodingReason::InvalidBooleanValue { value: 2 },
562        )
563    }
564
565    fn invalid_encoding_with_source() -> CodecError {
566        CodecError::invalid_encoding_for_operation_with_source(
567            CodecKind::JsonTextComponent,
568            CodecOperation::Read,
569            4,
570            InvalidEncodingReason::InvalidJson,
571            io::Error::new(io::ErrorKind::InvalidData, "bad json"),
572        )
573    }
574
575    #[test]
576    fn display_reports_unexpected_eof_operation_and_progress() {
577        assert_eq!(
578            read_error().to_string(),
579            "unexpected end of input while reading VarInt after 3 bytes: stream ended"
580        );
581    }
582
583    #[test]
584    fn display_reports_write_io_errors() {
585        assert_eq!(
586            write_error().to_string(),
587            "I/O error while writing String after 5 bytes: disk full"
588        );
589    }
590
591    #[test]
592    fn display_reports_invalid_encoding_reason() {
593        assert_eq!(
594            invalid_encoding_error().to_string(),
595            "invalid Boolean encoding after 1 bytes: invalid boolean value 0x02"
596        );
597    }
598
599    #[test]
600    fn display_appends_contexts_and_source_in_order() {
601        let error = invalid_encoding_with_source()
602            .with_context(CodecKind::String)
603            .with_context(CodecKind::Identifier)
604            .with_context(CodecKind::TextComponent);
605        assert_eq!(
606            error.to_string(),
607            "invalid JsonTextComponent encoding after 4 bytes: invalid JSON data \
608             while processing String while processing Identifier while processing TextComponent: bad json"
609        );
610    }
611
612    #[test]
613    fn display_omits_contexts_and_source_when_absent() {
614        let error = invalid_encoding_error();
615        assert!(!error.to_string().contains("while processing"));
616        assert!(
617            !error.to_string().ends_with(": invalid boolean value 0x02:"),
618            "a source was rendered when none is stored"
619        );
620    }
621
622    #[test]
623    fn contexts_are_empty_by_default() {
624        let error = read_error();
625        assert!(error.contexts().is_empty());
626        assert_eq!(error.context(), None);
627    }
628
629    #[test]
630    fn single_context_is_reported_inline() {
631        let error = read_error().with_context(CodecKind::String);
632        assert_eq!(error.contexts(), &[CodecKind::String]);
633        assert_eq!(error.context(), Some(CodecKind::String));
634    }
635
636    #[test]
637    fn many_contexts_are_reported_nearest_to_outermost() {
638        let error = invalid_encoding_error()
639            .with_context(CodecKind::String)
640            .with_context(CodecKind::Identifier)
641            .with_context(CodecKind::TextComponent);
642        assert_eq!(
643            error.contexts(),
644            &[
645                CodecKind::String,
646                CodecKind::Identifier,
647                CodecKind::TextComponent
648            ]
649        );
650        assert_eq!(error.context(), Some(CodecKind::TextComponent));
651        assert_eq!(error.codec(), CodecKind::Boolean);
652    }
653
654    #[test]
655    fn io_error_returns_the_underlying_io_error() {
656        let error = read_error();
657        let io_error = error.io_error().expect("io_error() should be Some");
658        assert_eq!(io_error.kind(), io::ErrorKind::UnexpectedEof);
659        assert_eq!(io_error.to_string(), "stream ended");
660        assert_eq!(
661            error
662                .source()
663                .and_then(|source| source.downcast_ref::<io::Error>())
664                .map(io::Error::kind),
665            Some(io::ErrorKind::UnexpectedEof)
666        );
667    }
668
669    #[test]
670    fn io_error_returns_none_for_non_io_sources() {
671        let error = CodecError::invalid_encoding_for_operation_with_source(
672            CodecKind::TextComponent,
673            CodecOperation::Read,
674            0,
675            InvalidEncodingReason::InvalidNbt,
676            NonIoSource,
677        );
678        assert!(error.io_error().is_none());
679        assert!(error.source().is_some());
680    }
681
682    #[derive(Debug)]
683    struct NonIoSource;
684
685    impl fmt::Display for NonIoSource {
686        fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
687            formatter.write_str("non-io source")
688        }
689    }
690
691    impl Error for NonIoSource {}
692}