Skip to main content

epub_stream/
error.rs

1//! Unified error types for epub-stream
2//!
3//! Provides a top-level `EpubError` that wraps module-specific errors,
4//! plus `From` impls so `?` works across module boundaries.
5
6extern crate alloc;
7
8use alloc::boxed::Box;
9use alloc::format;
10use alloc::string::{String, ToString};
11use core::fmt;
12
13/// Stable processing phases for typed EPUB failures.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15#[non_exhaustive]
16pub enum ErrorPhase {
17    /// EPUB open/bootstrap work (container + OPF discovery).
18    Open,
19    /// Generic parsing/tokenization work.
20    Parse,
21    /// Style/CSS preparation work.
22    Style,
23    /// Layout/pagination work.
24    Layout,
25    /// Backend rendering work.
26    Render,
27}
28
29impl fmt::Display for ErrorPhase {
30    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::Open => write!(f, "open"),
33            Self::Parse => write!(f, "parse"),
34            Self::Style => write!(f, "style"),
35            Self::Layout => write!(f, "layout"),
36            Self::Render => write!(f, "render"),
37        }
38    }
39}
40
41/// Typed actual-vs-limit payload for hard-cap failures.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ErrorLimitContext {
44    /// Stable limit field name (e.g. `max_css_bytes`).
45    pub kind: Box<str>,
46    /// Observed value.
47    pub actual: usize,
48    /// Configured cap.
49    pub limit: usize,
50}
51
52impl ErrorLimitContext {
53    /// Build a new limit context.
54    pub fn new(kind: impl Into<String>, actual: usize, limit: usize) -> Self {
55        Self {
56            kind: kind.into().into_boxed_str(),
57            actual,
58            limit,
59        }
60    }
61}
62
63/// Rich optional context for typed phase errors.
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
65pub struct PhaseErrorContext {
66    /// Optional archive path context.
67    pub path: Option<Box<str>>,
68    /// Optional resource href context.
69    pub href: Option<Box<str>>,
70    /// Optional chapter index.
71    pub chapter_index: Option<usize>,
72    /// Optional source context (e.g. inline style location).
73    pub source: Option<Box<str>>,
74    /// Optional selector context.
75    pub selector: Option<Box<str>>,
76    /// Optional selector index.
77    pub selector_index: Option<usize>,
78    /// Optional declaration context.
79    pub declaration: Option<Box<str>>,
80    /// Optional declaration index.
81    pub declaration_index: Option<usize>,
82    /// Optional tokenizer/read offset in bytes.
83    pub token_offset: Option<usize>,
84    /// Optional actual-vs-limit payload.
85    pub limit: Option<Box<ErrorLimitContext>>,
86}
87
88/// Typed error with explicit processing phase and context.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct PhaseError {
91    /// Stable processing phase.
92    pub phase: ErrorPhase,
93    /// Stable machine-readable code.
94    pub code: &'static str,
95    /// Human-readable message.
96    pub message: Box<str>,
97    /// Optional rich context.
98    pub context: Option<Box<PhaseErrorContext>>,
99}
100
101impl PhaseError {
102    /// Create a typed phase error.
103    pub fn new(phase: ErrorPhase, code: &'static str, message: impl Into<String>) -> Self {
104        Self {
105            phase,
106            code,
107            message: message.into().into_boxed_str(),
108            context: None,
109        }
110    }
111}
112
113/// Top-level error type for epub-stream operations
114#[derive(Debug, Clone, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum EpubError {
117    /// Typed phase-aware error with structured context.
118    Phase(PhaseError),
119    /// ZIP archive error
120    Zip(ZipError),
121    /// XML/XHTML parsing error
122    Parse(String),
123    /// Invalid EPUB structure (missing required files, broken references, etc.)
124    InvalidEpub(String),
125    /// Navigation parsing error
126    Navigation(String),
127    /// CSS parsing error
128    Css(String),
129    /// I/O error (description only, since `std::io::Error` is not `Clone`)
130    Io(String),
131    /// Chapter index requested is out of bounds
132    ChapterOutOfBounds {
133        /// Requested chapter index.
134        index: usize,
135        /// Total number of chapters available.
136        chapter_count: usize,
137    },
138    /// Spine references a manifest item that does not exist
139    ManifestItemMissing {
140        /// Missing manifest `id` referenced by spine `idref`.
141        idref: String,
142    },
143    /// Chapter content could not be decoded as UTF-8
144    ChapterNotUtf8 {
145        /// Chapter href/path in the EPUB archive.
146        href: String,
147    },
148    /// Hard limit exceeded (typed error instead of OOM)
149    LimitExceeded {
150        /// Kind of limit that was exceeded.
151        kind: LimitKind,
152        /// Actual value observed.
153        actual: usize,
154        /// Configured limit.
155        limit: usize,
156        /// Optional path context.
157        path: Option<String>,
158    },
159    /// Provided buffer is too small for the operation
160    BufferTooSmall {
161        /// Required size.
162        required: usize,
163        /// Provided size.
164        provided: usize,
165        /// Context about which buffer.
166        context: String,
167    },
168}
169
170/// Kinds of limits that can be exceeded.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172#[non_exhaustive]
173pub enum LimitKind {
174    /// File size limit.
175    FileSize,
176    /// Memory budget limit.
177    MemoryBudget,
178    /// Token/event count limit.
179    EventCount,
180    /// Nesting depth limit.
181    NestingDepth,
182    /// CSS stylesheet size limit.
183    CssSize,
184    /// Font count/size limit.
185    FontLimit,
186}
187
188impl fmt::Display for EpubError {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            EpubError::Phase(err) => write!(
192                f,
193                "{} error [{}]: {}",
194                err.phase.to_string().to_ascii_uppercase(),
195                err.code,
196                err.message
197            ),
198            EpubError::Zip(kind) => write!(f, "ZIP error: {}", kind),
199            EpubError::Parse(msg) => write!(f, "Parse error: {}", msg),
200            EpubError::InvalidEpub(msg) => write!(f, "Invalid EPUB: {}", msg),
201            EpubError::Navigation(msg) => write!(f, "Navigation error: {}", msg),
202            EpubError::Css(msg) => write!(f, "CSS error: {}", msg),
203            EpubError::Io(msg) => write!(f, "I/O error: {}", msg),
204            EpubError::ChapterOutOfBounds {
205                index,
206                chapter_count,
207            } => write!(
208                f,
209                "Chapter index {} out of bounds (chapter count: {})",
210                index, chapter_count
211            ),
212            EpubError::ManifestItemMissing { idref } => {
213                write!(f, "Spine item '{}' does not exist in manifest", idref)
214            }
215            EpubError::ChapterNotUtf8 { href } => {
216                write!(f, "Chapter content is not valid UTF-8: {}", href)
217            }
218            EpubError::LimitExceeded {
219                kind,
220                actual,
221                limit,
222                path,
223            } => {
224                write!(
225                    f,
226                    "{} limit exceeded: {} > {}{}",
227                    kind,
228                    actual,
229                    limit,
230                    path.as_ref()
231                        .map(|p| format!(" at {}", p))
232                        .unwrap_or_default()
233                )
234            }
235            EpubError::BufferTooSmall {
236                required,
237                provided,
238                context,
239            } => {
240                write!(
241                    f,
242                    "Buffer too small for {}: required {} bytes, provided {}",
243                    context, required, provided
244                )
245            }
246        }
247    }
248}
249
250impl fmt::Display for LimitKind {
251    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
252        match self {
253            LimitKind::FileSize => write!(f, "File size"),
254            LimitKind::MemoryBudget => write!(f, "Memory budget"),
255            LimitKind::EventCount => write!(f, "Event count"),
256            LimitKind::NestingDepth => write!(f, "Nesting depth"),
257            LimitKind::CssSize => write!(f, "CSS size"),
258            LimitKind::FontLimit => write!(f, "Font limit"),
259        }
260    }
261}
262
263/// ZIP-specific error variants
264#[derive(Debug, Clone, PartialEq, Eq)]
265#[non_exhaustive]
266pub enum ZipErrorKind {
267    /// File not found in archive
268    FileNotFound,
269    /// Invalid ZIP format
270    InvalidFormat,
271    /// Unsupported compression method
272    UnsupportedCompression,
273    /// Decompression failed
274    DecompressError,
275    /// CRC32 mismatch
276    CrcMismatch,
277    /// I/O error during ZIP operations
278    IoError,
279    /// Central directory full (exceeded max entries)
280    CentralDirFull,
281    /// Buffer too small for decompressed content
282    BufferTooSmall,
283    /// File exceeds maximum allowed size
284    FileTooLarge,
285    /// Invalid or missing mimetype file
286    InvalidMimetype(String),
287    /// ZIP64 structures are present but unsupported
288    UnsupportedZip64,
289}
290
291/// Public ZIP error type alias used across the crate API.
292pub type ZipError = ZipErrorKind;
293
294impl fmt::Display for ZipErrorKind {
295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
296        match self {
297            ZipErrorKind::FileNotFound => write!(f, "file not found in archive"),
298            ZipErrorKind::InvalidFormat => write!(f, "invalid ZIP format"),
299            ZipErrorKind::UnsupportedCompression => write!(f, "unsupported compression method"),
300            ZipErrorKind::DecompressError => write!(f, "decompression failed"),
301            ZipErrorKind::CrcMismatch => write!(f, "CRC32 checksum mismatch"),
302            ZipErrorKind::IoError => write!(f, "I/O error"),
303            ZipErrorKind::CentralDirFull => write!(f, "central directory full"),
304            ZipErrorKind::BufferTooSmall => write!(f, "buffer too small"),
305            ZipErrorKind::FileTooLarge => write!(f, "file too large"),
306            ZipErrorKind::InvalidMimetype(msg) => write!(f, "invalid mimetype: {}", msg),
307            ZipErrorKind::UnsupportedZip64 => write!(f, "ZIP64 is not supported"),
308        }
309    }
310}
311
312#[cfg(feature = "std")]
313impl std::error::Error for EpubError {}
314
315#[cfg(feature = "std")]
316impl std::error::Error for ZipErrorKind {}
317
318impl From<crate::tokenizer::TokenizeError> for EpubError {
319    fn from(err: crate::tokenizer::TokenizeError) -> Self {
320        EpubError::Parse(err.to_string())
321    }
322}
323
324impl From<PhaseError> for EpubError {
325    fn from(err: PhaseError) -> Self {
326        Self::Phase(err)
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_epub_error_display() {
336        let err = EpubError::Parse("bad xml".into());
337        assert_eq!(format!("{}", err), "Parse error: bad xml");
338    }
339
340    #[test]
341    fn test_phase_error_display() {
342        let err = EpubError::Phase(PhaseError::new(
343            ErrorPhase::Style,
344            "STYLE_LIMIT",
345            "limit exceeded",
346        ));
347        assert_eq!(
348            format!("{}", err),
349            "STYLE error [STYLE_LIMIT]: limit exceeded"
350        );
351    }
352
353    #[test]
354    fn test_zip_error_kind_debug() {
355        let kind = ZipErrorKind::FileNotFound;
356        assert_eq!(format!("{:?}", kind), "FileNotFound");
357    }
358
359    #[test]
360    fn test_invalid_mimetype_error() {
361        let err = EpubError::Zip(ZipErrorKind::InvalidMimetype("wrong content type".into()));
362        let display = format!("{}", err);
363        assert!(display.contains("ZIP error"));
364    }
365}