hwpforge_smithy_hwpx/
error.rs

1//! Error types for the HWPX decoder.
2//!
3//! Error codes occupy the 4000-4099 range, consistent with the
4//! Foundation (1000), Core (2000), and Blueprint (3000) convention.
5
6use std::fmt;
7
8/// Top-level error type for HWPX decoding operations.
9///
10/// Every fallible operation in smithy-hwpx returns `Result<T, HwpxError>`.
11/// Both [`hwpforge_core::CoreError`] and
12/// [`hwpforge_foundation::FoundationError`] convert via `#[from]`.
13///
14/// # Examples
15///
16/// ```
17/// use hwpforge_smithy_hwpx::HwpxError;
18///
19/// let err = HwpxError::MissingFile {
20///     path: "Contents/header.xml".into(),
21/// };
22/// assert!(err.to_string().contains("header.xml"));
23/// ```
24#[derive(Debug, thiserror::Error)]
25#[non_exhaustive]
26pub enum HwpxError {
27    /// ZIP archive could not be read or is corrupt.
28    #[error("ZIP error: {0}")]
29    Zip(String),
30
31    /// The `mimetype` entry has an unexpected value.
32    #[error("Invalid HWPX mimetype: expected 'application/hwp+zip', got '{actual}'")]
33    InvalidMimetype {
34        /// The value found in the archive.
35        actual: String,
36    },
37
38    /// A required file is missing from the ZIP archive.
39    #[error("Missing required file in HWPX archive: '{path}'")]
40    MissingFile {
41        /// The expected path inside the archive.
42        path: String,
43    },
44
45    /// XML could not be deserialized.
46    #[error("XML parse error in '{file}': {detail}")]
47    XmlParse {
48        /// Which file inside the archive failed.
49        file: String,
50        /// The underlying parse error message.
51        detail: String,
52    },
53
54    /// An attribute value could not be converted.
55    #[error("Invalid attribute '{attribute}' on <{element}>: '{value}'")]
56    InvalidAttribute {
57        /// The XML element name.
58        element: String,
59        /// The attribute name.
60        attribute: String,
61        /// The rejected value.
62        value: String,
63    },
64
65    /// A style index reference exceeds the header's definition count.
66    #[error("{kind} index {index} out of bounds (max: {max})")]
67    IndexOutOfBounds {
68        /// What kind of index (e.g. "charPrIDRef", "paraPrIDRef").
69        kind: &'static str,
70        /// The rejected index value.
71        index: u32,
72        /// The upper bound (exclusive).
73        max: u32,
74    },
75
76    /// Structural issue in the HWPX content.
77    #[error("Invalid HWPX structure: {detail}")]
78    InvalidStructure {
79        /// What went wrong.
80        detail: String,
81    },
82
83    /// An I/O error occurred (e.g. reading a file from disk).
84    #[error("I/O error: {0}")]
85    Io(#[from] std::io::Error),
86
87    /// A Core-layer error propagated upward.
88    #[error("Core error: {0}")]
89    Core(#[from] hwpforge_core::CoreError),
90
91    /// A Foundation-layer error propagated upward.
92    #[error("Foundation error: {0}")]
93    Foundation(#[from] hwpforge_foundation::FoundationError),
94
95    /// XML serialization failure (encoder).
96    #[error("XML serialization error: {detail}")]
97    XmlSerialize {
98        /// The serialization error message.
99        detail: String,
100    },
101}
102
103/// Error codes for smithy-hwpx (4000-4099 range).
104///
105/// These follow the same convention as Foundation (1000-1099),
106/// Core (2000-2099), and Blueprint (3000-3099).
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108#[non_exhaustive]
109pub enum HwpxErrorCode {
110    /// Generic ZIP failure.
111    Zip = 4000,
112    /// Invalid mimetype in archive.
113    InvalidMimetype = 4001,
114    /// Required file missing from archive.
115    MissingFile = 4002,
116    /// XML deserialization failure.
117    XmlParse = 4003,
118    /// Bad attribute value during conversion.
119    InvalidAttribute = 4004,
120    /// Style index reference out of range.
121    IndexOutOfBounds = 4005,
122    /// Structural issue.
123    InvalidStructure = 4006,
124    /// I/O failure.
125    Io = 4007,
126    /// Propagated Core error.
127    Core = 4008,
128    /// Propagated Foundation error.
129    Foundation = 4009,
130    /// XML serialization failure.
131    XmlSerialize = 4010,
132}
133
134impl fmt::Display for HwpxErrorCode {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        write!(f, "E{}", *self as u16)
137    }
138}
139
140impl HwpxError {
141    /// Returns the corresponding error code.
142    pub fn code(&self) -> HwpxErrorCode {
143        match self {
144            Self::Zip(_) => HwpxErrorCode::Zip,
145            Self::InvalidMimetype { .. } => HwpxErrorCode::InvalidMimetype,
146            Self::MissingFile { .. } => HwpxErrorCode::MissingFile,
147            Self::XmlParse { .. } => HwpxErrorCode::XmlParse,
148            Self::InvalidAttribute { .. } => HwpxErrorCode::InvalidAttribute,
149            Self::IndexOutOfBounds { .. } => HwpxErrorCode::IndexOutOfBounds,
150            Self::InvalidStructure { .. } => HwpxErrorCode::InvalidStructure,
151            Self::Io(_) => HwpxErrorCode::Io,
152            Self::Core(_) => HwpxErrorCode::Core,
153            Self::Foundation(_) => HwpxErrorCode::Foundation,
154            Self::XmlSerialize { .. } => HwpxErrorCode::XmlSerialize,
155        }
156    }
157}
158
159/// Convenience alias used throughout this crate.
160pub type HwpxResult<T> = Result<T, HwpxError>;
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn zip_error_display() {
168        let err = HwpxError::Zip("corrupt archive".into());
169        assert_eq!(err.to_string(), "ZIP error: corrupt archive");
170        assert_eq!(err.code(), HwpxErrorCode::Zip);
171    }
172
173    #[test]
174    fn invalid_mimetype_display() {
175        let err = HwpxError::InvalidMimetype { actual: "application/zip".into() };
176        let msg = err.to_string();
177        assert!(msg.contains("application/hwp+zip"));
178        assert!(msg.contains("application/zip"));
179        assert_eq!(err.code(), HwpxErrorCode::InvalidMimetype);
180    }
181
182    #[test]
183    fn missing_file_display() {
184        let err = HwpxError::MissingFile { path: "Contents/header.xml".into() };
185        assert!(err.to_string().contains("header.xml"));
186        assert_eq!(err.code(), HwpxErrorCode::MissingFile);
187    }
188
189    #[test]
190    fn xml_parse_display() {
191        let err = HwpxError::XmlParse {
192            file: "Contents/section0.xml".into(),
193            detail: "unexpected element 'foo'".into(),
194        };
195        let msg = err.to_string();
196        assert!(msg.contains("section0.xml"));
197        assert!(msg.contains("unexpected element"));
198        assert_eq!(err.code(), HwpxErrorCode::XmlParse);
199    }
200
201    #[test]
202    fn invalid_attribute_display() {
203        let err = HwpxError::InvalidAttribute {
204            element: "hh:charPr".into(),
205            attribute: "height".into(),
206            value: "abc".into(),
207        };
208        let msg = err.to_string();
209        assert!(msg.contains("hh:charPr"));
210        assert!(msg.contains("height"));
211        assert!(msg.contains("abc"));
212        assert_eq!(err.code(), HwpxErrorCode::InvalidAttribute);
213    }
214
215    #[test]
216    fn index_out_of_bounds_display() {
217        let err = HwpxError::IndexOutOfBounds { kind: "charPrIDRef", index: 99, max: 5 };
218        let msg = err.to_string();
219        assert!(msg.contains("charPrIDRef"));
220        assert!(msg.contains("99"));
221        assert!(msg.contains("5"));
222        assert_eq!(err.code(), HwpxErrorCode::IndexOutOfBounds);
223    }
224
225    #[test]
226    fn invalid_structure_display() {
227        let err = HwpxError::InvalidStructure { detail: "section has no paragraphs".into() };
228        assert!(err.to_string().contains("no paragraphs"));
229        assert_eq!(err.code(), HwpxErrorCode::InvalidStructure);
230    }
231
232    #[test]
233    fn io_error_conversion() {
234        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
235        let err: HwpxError = io_err.into();
236        assert_eq!(err.code(), HwpxErrorCode::Io);
237        assert!(err.to_string().contains("file not found"));
238    }
239
240    #[test]
241    fn error_code_display() {
242        assert_eq!(HwpxErrorCode::Zip.to_string(), "E4000");
243        assert_eq!(HwpxErrorCode::InvalidMimetype.to_string(), "E4001");
244        assert_eq!(HwpxErrorCode::MissingFile.to_string(), "E4002");
245        assert_eq!(HwpxErrorCode::XmlParse.to_string(), "E4003");
246        assert_eq!(HwpxErrorCode::InvalidAttribute.to_string(), "E4004");
247        assert_eq!(HwpxErrorCode::IndexOutOfBounds.to_string(), "E4005");
248        assert_eq!(HwpxErrorCode::InvalidStructure.to_string(), "E4006");
249        assert_eq!(HwpxErrorCode::Io.to_string(), "E4007");
250        assert_eq!(HwpxErrorCode::Core.to_string(), "E4008");
251        assert_eq!(HwpxErrorCode::Foundation.to_string(), "E4009");
252        assert_eq!(HwpxErrorCode::XmlSerialize.to_string(), "E4010");
253    }
254
255    #[test]
256    fn xml_serialize_error_display() {
257        let err = HwpxError::XmlSerialize { detail: "missing field".into() };
258        assert!(err.to_string().contains("missing field"));
259        assert_eq!(err.code(), HwpxErrorCode::XmlSerialize);
260    }
261
262    #[test]
263    fn error_codes_are_in_4000_range() {
264        let codes = [
265            HwpxErrorCode::Zip,
266            HwpxErrorCode::InvalidMimetype,
267            HwpxErrorCode::MissingFile,
268            HwpxErrorCode::XmlParse,
269            HwpxErrorCode::InvalidAttribute,
270            HwpxErrorCode::IndexOutOfBounds,
271            HwpxErrorCode::InvalidStructure,
272            HwpxErrorCode::Io,
273            HwpxErrorCode::Core,
274            HwpxErrorCode::Foundation,
275            HwpxErrorCode::XmlSerialize,
276        ];
277        for code in codes {
278            let val = code as u16;
279            assert!((4000..4100).contains(&val), "code {val} not in 4000-4099");
280        }
281    }
282
283    #[test]
284    fn hwpx_result_type_alias_works() {
285        fn example() -> HwpxResult<u32> {
286            Ok(42)
287        }
288        assert_eq!(example().unwrap(), 42);
289    }
290
291    #[test]
292    fn hwpx_result_err_path() {
293        fn example() -> HwpxResult<u32> {
294            Err(HwpxError::Zip("test".into()))
295        }
296        assert!(example().is_err());
297    }
298
299    #[test]
300    fn error_is_send_and_sync() {
301        fn assert_send<T: Send>() {}
302        fn assert_sync<T: Sync>() {}
303        assert_send::<HwpxError>();
304        assert_sync::<HwpxError>();
305    }
306
307    #[test]
308    fn foundation_error_conversion() {
309        let fe = hwpforge_foundation::FoundationError::EmptyIdentifier { item: "FontId".into() };
310        let err: HwpxError = fe.into();
311        assert_eq!(err.code(), HwpxErrorCode::Foundation);
312    }
313}