Skip to main content

hwpforge_core/
error.rs

1//! Error types for the HwpForge Core crate.
2//!
3//! All validation and structural errors produced by Core live here.
4//! Error codes occupy the **2000-2999** range, extending the Foundation
5//! convention (1000-1999).
6//!
7//! # Error Hierarchy
8//!
9//! [`CoreError`] is the top-level error. It wraps:
10//! - [`ValidationError`] -- document structure validation failures
11//! - [`FoundationError`] -- propagated Foundation errors
12//! - `InvalidStructure` -- catch-all for structural issues
13//!
14//! # Examples
15//!
16//! ```
17//! use hwpforge_core::error::{CoreError, ValidationError};
18//!
19//! let err = CoreError::from(ValidationError::EmptyDocument);
20//! assert!(err.to_string().contains("section"));
21//! ```
22
23use hwpforge_foundation::FoundationError;
24
25/// Top-level error type for the Core crate.
26///
27/// Every fallible operation in Core returns `Result<T, CoreError>`.
28/// Use the `?` operator freely -- both [`ValidationError`] and
29/// [`FoundationError`] convert via `#[from]`.
30///
31/// # Examples
32///
33/// ```
34/// use hwpforge_core::error::{CoreError, ValidationError};
35///
36/// fn example() -> Result<(), CoreError> {
37///     Err(ValidationError::EmptyDocument)?
38/// }
39/// assert!(example().is_err());
40/// ```
41#[derive(Debug, thiserror::Error)]
42#[non_exhaustive]
43pub enum CoreError {
44    /// Document validation failed.
45    #[error("Document validation failed: {0}")]
46    Validation(#[from] ValidationError),
47
48    /// A Foundation-layer error propagated upward.
49    #[error("Foundation error: {0}")]
50    Foundation(#[from] FoundationError),
51
52    /// Structural issue that is not a validation failure.
53    #[error("Invalid document structure in {context}: {reason}")]
54    InvalidStructure {
55        /// Where in the document the issue was found.
56        context: String,
57        /// What went wrong.
58        reason: String,
59    },
60}
61
62/// Specific validation failures with precise location context.
63///
64/// Every variant carries enough information to pinpoint the
65/// exact location of the problem (section index, paragraph index, etc.).
66///
67/// Marked `#[non_exhaustive]` so future phases can add variants
68/// without a breaking change.
69///
70/// # Examples
71///
72/// ```
73/// use hwpforge_core::error::ValidationError;
74///
75/// let err = ValidationError::EmptySection { section_index: 2 };
76/// assert!(err.to_string().contains("Section 2"));
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
79#[non_exhaustive]
80pub enum ValidationError {
81    /// The document contains zero sections.
82    #[error("Empty document: at least 1 section required")]
83    EmptyDocument,
84
85    /// A section contains zero paragraphs.
86    #[error("Section {section_index} has no paragraphs")]
87    EmptySection {
88        /// Zero-based index of the offending section.
89        section_index: usize,
90    },
91
92    /// A paragraph contains zero runs.
93    #[error("Paragraph has no runs (section {section_index}, paragraph {paragraph_index})")]
94    EmptyParagraph {
95        /// Zero-based section index.
96        section_index: usize,
97        /// Zero-based paragraph index within the section.
98        paragraph_index: usize,
99    },
100
101    /// A table contains zero rows.
102    #[error(
103        "Table has no rows (section {section_index}, paragraph {paragraph_index}, run {run_index})"
104    )]
105    EmptyTable {
106        /// Zero-based section index.
107        section_index: usize,
108        /// Zero-based paragraph index.
109        paragraph_index: usize,
110        /// Zero-based run index.
111        run_index: usize,
112    },
113
114    /// A table row contains zero cells.
115    #[error("Table row has no cells (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index})")]
116    EmptyTableRow {
117        /// Zero-based section index.
118        section_index: usize,
119        /// Zero-based paragraph index.
120        paragraph_index: usize,
121        /// Zero-based run index.
122        run_index: usize,
123        /// Zero-based row index within the table.
124        row_index: usize,
125    },
126
127    /// A span value (col_span or row_span) is zero.
128    #[error("Invalid span: {field} = {value} (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index}, cell {cell_index})")]
129    InvalidSpan {
130        /// Which span field failed ("col_span" or "row_span").
131        field: &'static str,
132        /// The invalid value.
133        value: u16,
134        /// Zero-based section index.
135        section_index: usize,
136        /// Zero-based paragraph index.
137        paragraph_index: usize,
138        /// Zero-based run index.
139        run_index: usize,
140        /// Zero-based row index.
141        row_index: usize,
142        /// Zero-based cell index.
143        cell_index: usize,
144    },
145
146    /// A TextBox control contains zero paragraphs.
147    #[error("TextBox has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
148    EmptyTextBox {
149        /// Zero-based section index.
150        section_index: usize,
151        /// Zero-based paragraph index.
152        paragraph_index: usize,
153        /// Zero-based run index.
154        run_index: usize,
155    },
156
157    /// A Footnote control contains zero paragraphs.
158    #[error("Footnote has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
159    EmptyFootnote {
160        /// Zero-based section index.
161        section_index: usize,
162        /// Zero-based paragraph index.
163        paragraph_index: usize,
164        /// Zero-based run index.
165        run_index: usize,
166    },
167
168    /// An Endnote control contains zero paragraphs.
169    #[error("Endnote has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
170    EmptyEndnote {
171        /// Zero-based section index.
172        section_index: usize,
173        /// Zero-based paragraph index.
174        paragraph_index: usize,
175        /// Zero-based run index.
176        run_index: usize,
177    },
178
179    /// A Polygon control has fewer than 3 vertices.
180    #[error("Polygon has invalid vertex count: {vertex_count} (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
181    InvalidPolygon {
182        /// Zero-based section index.
183        section_index: usize,
184        /// Zero-based paragraph index.
185        paragraph_index: usize,
186        /// Zero-based run index.
187        run_index: usize,
188        /// Number of vertices found.
189        vertex_count: usize,
190    },
191
192    /// A shape (Ellipse or Polygon) has zero width or height.
193    #[error("Shape {shape_type} has zero dimension (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
194    InvalidShapeDimension {
195        /// Zero-based section index.
196        section_index: usize,
197        /// Zero-based paragraph index.
198        paragraph_index: usize,
199        /// Zero-based run index.
200        run_index: usize,
201        /// Type of shape ("Ellipse" or "Polygon").
202        shape_type: &'static str,
203    },
204
205    /// A Chart control has no data series.
206    #[error("Chart has empty data (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
207    EmptyChartData {
208        /// Zero-based section index.
209        section_index: usize,
210        /// Zero-based paragraph index.
211        paragraph_index: usize,
212        /// Zero-based run index.
213        run_index: usize,
214    },
215
216    /// An Equation control has an empty script.
217    #[error("Equation has empty script (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
218    EmptyEquation {
219        /// Zero-based section index.
220        section_index: usize,
221        /// Zero-based paragraph index.
222        paragraph_index: usize,
223        /// Zero-based run index.
224        run_index: usize,
225    },
226
227    /// A Category chart has an empty categories list.
228    #[error("Chart has empty category labels (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
229    EmptyCategoryLabels {
230        /// Zero-based section index.
231        section_index: usize,
232        /// Zero-based paragraph index.
233        paragraph_index: usize,
234        /// Zero-based run index.
235        run_index: usize,
236    },
237
238    /// An XY series has mismatched x/y value lengths.
239    #[error("XY series '{series_name}' has mismatched lengths: x={x_len}, y={y_len} (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
240    MismatchedSeriesLengths {
241        /// Zero-based section index.
242        section_index: usize,
243        /// Zero-based paragraph index.
244        paragraph_index: usize,
245        /// Zero-based run index.
246        run_index: usize,
247        /// Name of the offending series.
248        series_name: String,
249        /// Length of x_values.
250        x_len: usize,
251        /// Length of y_values.
252        y_len: usize,
253    },
254
255    /// A table cell contains zero paragraphs.
256    #[error("Table cell has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index}, cell {cell_index})")]
257    EmptyTableCell {
258        /// Zero-based section index.
259        section_index: usize,
260        /// Zero-based paragraph index.
261        paragraph_index: usize,
262        /// Zero-based run index.
263        run_index: usize,
264        /// Zero-based row index.
265        row_index: usize,
266        /// Zero-based cell index.
267        cell_index: usize,
268    },
269
270    /// A header row appears after a non-header row.
271    #[error("Table header row is not part of the leading header block (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index})")]
272    NonLeadingTableHeaderRow {
273        /// Zero-based section index.
274        section_index: usize,
275        /// Zero-based paragraph index.
276        paragraph_index: usize,
277        /// Zero-based run index.
278        run_index: usize,
279        /// Zero-based row index.
280        row_index: usize,
281    },
282}
283
284// ---------------------------------------------------------------------------
285// ErrorCode integration
286// ---------------------------------------------------------------------------
287
288/// Core validation error codes (2000-2099).
289///
290/// Extends Foundation's [`ErrorCode`](hwpforge_foundation::ErrorCode) convention into the Core range.
291///
292/// # Examples
293///
294/// ```
295/// use hwpforge_core::error::CoreErrorCode;
296///
297/// assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
298/// ```
299#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
300#[repr(u32)]
301pub enum CoreErrorCode {
302    /// Empty document (no sections).
303    EmptyDocument = 2000,
304    /// Empty section (no paragraphs).
305    EmptySection = 2001,
306    /// Empty paragraph (no runs).
307    EmptyParagraph = 2002,
308    /// Empty table (no rows).
309    EmptyTable = 2003,
310    /// Empty table row (no cells).
311    EmptyTableRow = 2004,
312    /// Invalid span value (zero).
313    InvalidSpan = 2005,
314    /// Empty TextBox (no paragraphs).
315    EmptyTextBox = 2006,
316    /// Empty Footnote (no paragraphs).
317    EmptyFootnote = 2007,
318    /// Empty table cell (no paragraphs).
319    EmptyTableCell = 2008,
320    /// Empty Endnote (no paragraphs).
321    EmptyEndnote = 2009,
322    /// Invalid Polygon (fewer than 3 vertices).
323    InvalidPolygon = 2010,
324    /// Invalid shape dimension (zero width or height).
325    InvalidShapeDimension = 2011,
326    /// Empty Equation (empty script).
327    EmptyEquation = 2012,
328    /// Empty Chart data (no series).
329    EmptyChartData = 2013,
330    /// Empty category labels in a Category chart.
331    EmptyCategoryLabels = 2014,
332    /// Mismatched x/y value lengths in an XY series.
333    MismatchedSeriesLengths = 2015,
334    /// Non-leading table header row.
335    NonLeadingTableHeaderRow = 2016,
336    /// Invalid document structure.
337    InvalidStructure = 2100,
338}
339
340impl std::fmt::Display for CoreErrorCode {
341    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
342        write!(f, "E{:04}", *self as u32)
343    }
344}
345
346impl ValidationError {
347    /// Returns the numeric error code for this validation error.
348    pub fn code(&self) -> CoreErrorCode {
349        match self {
350            Self::EmptyDocument => CoreErrorCode::EmptyDocument,
351            Self::EmptySection { .. } => CoreErrorCode::EmptySection,
352            Self::EmptyParagraph { .. } => CoreErrorCode::EmptyParagraph,
353            Self::EmptyTable { .. } => CoreErrorCode::EmptyTable,
354            Self::EmptyTableRow { .. } => CoreErrorCode::EmptyTableRow,
355            Self::InvalidSpan { .. } => CoreErrorCode::InvalidSpan,
356            Self::NonLeadingTableHeaderRow { .. } => CoreErrorCode::NonLeadingTableHeaderRow,
357            Self::EmptyTextBox { .. } => CoreErrorCode::EmptyTextBox,
358            Self::EmptyFootnote { .. } => CoreErrorCode::EmptyFootnote,
359            Self::EmptyTableCell { .. } => CoreErrorCode::EmptyTableCell,
360            Self::EmptyEndnote { .. } => CoreErrorCode::EmptyEndnote,
361            Self::InvalidPolygon { .. } => CoreErrorCode::InvalidPolygon,
362            Self::InvalidShapeDimension { .. } => CoreErrorCode::InvalidShapeDimension,
363            Self::EmptyChartData { .. } => CoreErrorCode::EmptyChartData,
364            Self::EmptyCategoryLabels { .. } => CoreErrorCode::EmptyCategoryLabels,
365            Self::MismatchedSeriesLengths { .. } => CoreErrorCode::MismatchedSeriesLengths,
366            Self::EmptyEquation { .. } => CoreErrorCode::EmptyEquation,
367        }
368    }
369}
370
371/// Convenience type alias for Core operations.
372///
373/// # Examples
374///
375/// ```
376/// use hwpforge_core::error::CoreResult;
377///
378/// fn always_ok() -> CoreResult<i32> {
379///     Ok(42)
380/// }
381/// assert_eq!(always_ok().unwrap(), 42);
382/// ```
383pub type CoreResult<T> = Result<T, CoreError>;
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    // === Variant construction ===
390
391    #[test]
392    fn empty_document_displays_message() {
393        let err = ValidationError::EmptyDocument;
394        let msg = err.to_string();
395        assert!(msg.contains("section"), "msg: {msg}");
396        assert!(msg.contains("at least 1"), "msg: {msg}");
397    }
398
399    #[test]
400    fn empty_section_displays_index() {
401        let err = ValidationError::EmptySection { section_index: 3 };
402        let msg = err.to_string();
403        assert!(msg.contains("3"), "msg: {msg}");
404        assert!(msg.contains("no paragraphs"), "msg: {msg}");
405    }
406
407    #[test]
408    fn empty_paragraph_displays_location() {
409        let err = ValidationError::EmptyParagraph { section_index: 1, paragraph_index: 5 };
410        let msg = err.to_string();
411        assert!(msg.contains("section 1"), "msg: {msg}");
412        assert!(msg.contains("paragraph 5"), "msg: {msg}");
413    }
414
415    #[test]
416    fn empty_table_displays_location() {
417        let err =
418            ValidationError::EmptyTable { section_index: 0, paragraph_index: 2, run_index: 0 };
419        let msg = err.to_string();
420        assert!(msg.contains("no rows"), "msg: {msg}");
421    }
422
423    #[test]
424    fn empty_table_row_displays_location() {
425        let err = ValidationError::EmptyTableRow {
426            section_index: 0,
427            paragraph_index: 0,
428            run_index: 0,
429            row_index: 1,
430        };
431        let msg = err.to_string();
432        assert!(msg.contains("row 1"), "msg: {msg}");
433        assert!(msg.contains("no cells"), "msg: {msg}");
434    }
435
436    #[test]
437    fn invalid_span_displays_all_context() {
438        let err = ValidationError::InvalidSpan {
439            field: "col_span",
440            value: 0,
441            section_index: 0,
442            paragraph_index: 1,
443            run_index: 0,
444            row_index: 0,
445            cell_index: 2,
446        };
447        let msg = err.to_string();
448        assert!(msg.contains("col_span"), "msg: {msg}");
449        assert!(msg.contains("= 0"), "msg: {msg}");
450        assert!(msg.contains("cell 2"), "msg: {msg}");
451    }
452
453    #[test]
454    fn empty_text_box_displays_location() {
455        let err =
456            ValidationError::EmptyTextBox { section_index: 0, paragraph_index: 0, run_index: 1 };
457        let msg = err.to_string();
458        assert!(msg.contains("TextBox"), "msg: {msg}");
459    }
460
461    #[test]
462    fn empty_footnote_displays_location() {
463        let err =
464            ValidationError::EmptyFootnote { section_index: 0, paragraph_index: 0, run_index: 0 };
465        let msg = err.to_string();
466        assert!(msg.contains("Footnote"), "msg: {msg}");
467    }
468
469    #[test]
470    fn empty_table_cell_displays_location() {
471        let err = ValidationError::EmptyTableCell {
472            section_index: 0,
473            paragraph_index: 0,
474            run_index: 0,
475            row_index: 0,
476            cell_index: 0,
477        };
478        let msg = err.to_string();
479        assert!(msg.contains("cell"), "msg: {msg}");
480    }
481
482    // === CoreError wrapping ===
483
484    #[test]
485    fn core_error_from_validation() {
486        let ve = ValidationError::EmptyDocument;
487        let ce: CoreError = ve.into();
488        match ce {
489            CoreError::Validation(v) => assert_eq!(v, ValidationError::EmptyDocument),
490            other => panic!("expected Validation, got: {other}"),
491        }
492    }
493
494    #[test]
495    fn core_error_from_foundation() {
496        let fe =
497            FoundationError::InvalidField { field: "test".to_string(), reason: "bad".to_string() };
498        let ce: CoreError = fe.into();
499        assert!(matches!(ce, CoreError::Foundation(_)));
500    }
501
502    #[test]
503    fn core_error_invalid_structure() {
504        let ce = CoreError::InvalidStructure {
505            context: "document".to_string(),
506            reason: "circular reference".to_string(),
507        };
508        let msg = ce.to_string();
509        assert!(msg.contains("document"), "msg: {msg}");
510        assert!(msg.contains("circular"), "msg: {msg}");
511    }
512
513    // === Error codes ===
514
515    #[test]
516    fn error_codes_in_core_range() {
517        assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
518        assert_eq!(CoreErrorCode::EmptySection as u32, 2001);
519        assert_eq!(CoreErrorCode::EmptyParagraph as u32, 2002);
520        assert_eq!(CoreErrorCode::EmptyTable as u32, 2003);
521        assert_eq!(CoreErrorCode::EmptyTableRow as u32, 2004);
522        assert_eq!(CoreErrorCode::InvalidSpan as u32, 2005);
523        assert_eq!(CoreErrorCode::EmptyTextBox as u32, 2006);
524        assert_eq!(CoreErrorCode::EmptyFootnote as u32, 2007);
525        assert_eq!(CoreErrorCode::EmptyTableCell as u32, 2008);
526        assert_eq!(CoreErrorCode::InvalidStructure as u32, 2100);
527    }
528
529    #[test]
530    fn error_code_display_format() {
531        assert_eq!(CoreErrorCode::EmptyDocument.to_string(), "E2000");
532        assert_eq!(CoreErrorCode::InvalidStructure.to_string(), "E2100");
533    }
534
535    #[test]
536    fn validation_error_code_mapping() {
537        assert_eq!(ValidationError::EmptyDocument.code(), CoreErrorCode::EmptyDocument);
538        assert_eq!(
539            ValidationError::EmptySection { section_index: 0 }.code(),
540            CoreErrorCode::EmptySection
541        );
542        assert_eq!(
543            ValidationError::EmptyParagraph { section_index: 0, paragraph_index: 0 }.code(),
544            CoreErrorCode::EmptyParagraph
545        );
546    }
547
548    // === CoreResult alias ===
549
550    #[test]
551    fn core_result_alias_works() {
552        fn ok_example() -> CoreResult<i32> {
553            Ok(42)
554        }
555        fn err_example() -> CoreResult<i32> {
556            Err(ValidationError::EmptyDocument)?
557        }
558        assert_eq!(ok_example().unwrap(), 42);
559        assert!(err_example().is_err());
560    }
561
562    // === Send + Sync ===
563
564    #[test]
565    fn errors_are_send_and_sync() {
566        fn assert_send<T: Send>() {}
567        fn assert_sync<T: Sync>() {}
568        assert_send::<CoreError>();
569        assert_sync::<CoreError>();
570        assert_send::<ValidationError>();
571        assert_sync::<ValidationError>();
572    }
573
574    // === std::error::Error ===
575
576    #[test]
577    fn core_error_implements_std_error() {
578        let err = CoreError::from(ValidationError::EmptyDocument);
579        let _: &dyn std::error::Error = &err;
580    }
581
582    // === ValidationError PartialEq ===
583
584    #[test]
585    fn validation_error_eq() {
586        let a = ValidationError::EmptyDocument;
587        let b = ValidationError::EmptyDocument;
588        let c = ValidationError::EmptySection { section_index: 0 };
589        assert_eq!(a, b);
590        assert_ne!(a, c);
591    }
592}