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
271// ---------------------------------------------------------------------------
272// ErrorCode integration
273// ---------------------------------------------------------------------------
274
275/// Core validation error codes (2000-2099).
276///
277/// Extends Foundation's [`ErrorCode`](hwpforge_foundation::ErrorCode) convention into the Core range.
278///
279/// # Examples
280///
281/// ```
282/// use hwpforge_core::error::CoreErrorCode;
283///
284/// assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
285/// ```
286#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
287#[repr(u32)]
288pub enum CoreErrorCode {
289    /// Empty document (no sections).
290    EmptyDocument = 2000,
291    /// Empty section (no paragraphs).
292    EmptySection = 2001,
293    /// Empty paragraph (no runs).
294    EmptyParagraph = 2002,
295    /// Empty table (no rows).
296    EmptyTable = 2003,
297    /// Empty table row (no cells).
298    EmptyTableRow = 2004,
299    /// Invalid span value (zero).
300    InvalidSpan = 2005,
301    /// Empty TextBox (no paragraphs).
302    EmptyTextBox = 2006,
303    /// Empty Footnote (no paragraphs).
304    EmptyFootnote = 2007,
305    /// Empty table cell (no paragraphs).
306    EmptyTableCell = 2008,
307    /// Empty Endnote (no paragraphs).
308    EmptyEndnote = 2009,
309    /// Invalid Polygon (fewer than 3 vertices).
310    InvalidPolygon = 2010,
311    /// Invalid shape dimension (zero width or height).
312    InvalidShapeDimension = 2011,
313    /// Empty Equation (empty script).
314    EmptyEquation = 2012,
315    /// Empty Chart data (no series).
316    EmptyChartData = 2013,
317    /// Empty category labels in a Category chart.
318    EmptyCategoryLabels = 2014,
319    /// Mismatched x/y value lengths in an XY series.
320    MismatchedSeriesLengths = 2015,
321    /// Invalid document structure.
322    InvalidStructure = 2100,
323}
324
325impl std::fmt::Display for CoreErrorCode {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        write!(f, "E{:04}", *self as u32)
328    }
329}
330
331impl ValidationError {
332    /// Returns the numeric error code for this validation error.
333    pub fn code(&self) -> CoreErrorCode {
334        match self {
335            Self::EmptyDocument => CoreErrorCode::EmptyDocument,
336            Self::EmptySection { .. } => CoreErrorCode::EmptySection,
337            Self::EmptyParagraph { .. } => CoreErrorCode::EmptyParagraph,
338            Self::EmptyTable { .. } => CoreErrorCode::EmptyTable,
339            Self::EmptyTableRow { .. } => CoreErrorCode::EmptyTableRow,
340            Self::InvalidSpan { .. } => CoreErrorCode::InvalidSpan,
341            Self::EmptyTextBox { .. } => CoreErrorCode::EmptyTextBox,
342            Self::EmptyFootnote { .. } => CoreErrorCode::EmptyFootnote,
343            Self::EmptyTableCell { .. } => CoreErrorCode::EmptyTableCell,
344            Self::EmptyEndnote { .. } => CoreErrorCode::EmptyEndnote,
345            Self::InvalidPolygon { .. } => CoreErrorCode::InvalidPolygon,
346            Self::InvalidShapeDimension { .. } => CoreErrorCode::InvalidShapeDimension,
347            Self::EmptyChartData { .. } => CoreErrorCode::EmptyChartData,
348            Self::EmptyCategoryLabels { .. } => CoreErrorCode::EmptyCategoryLabels,
349            Self::MismatchedSeriesLengths { .. } => CoreErrorCode::MismatchedSeriesLengths,
350            Self::EmptyEquation { .. } => CoreErrorCode::EmptyEquation,
351        }
352    }
353}
354
355/// Convenience type alias for Core operations.
356///
357/// # Examples
358///
359/// ```
360/// use hwpforge_core::error::CoreResult;
361///
362/// fn always_ok() -> CoreResult<i32> {
363///     Ok(42)
364/// }
365/// assert_eq!(always_ok().unwrap(), 42);
366/// ```
367pub type CoreResult<T> = Result<T, CoreError>;
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    // === Variant construction ===
374
375    #[test]
376    fn empty_document_displays_message() {
377        let err = ValidationError::EmptyDocument;
378        let msg = err.to_string();
379        assert!(msg.contains("section"), "msg: {msg}");
380        assert!(msg.contains("at least 1"), "msg: {msg}");
381    }
382
383    #[test]
384    fn empty_section_displays_index() {
385        let err = ValidationError::EmptySection { section_index: 3 };
386        let msg = err.to_string();
387        assert!(msg.contains("3"), "msg: {msg}");
388        assert!(msg.contains("no paragraphs"), "msg: {msg}");
389    }
390
391    #[test]
392    fn empty_paragraph_displays_location() {
393        let err = ValidationError::EmptyParagraph { section_index: 1, paragraph_index: 5 };
394        let msg = err.to_string();
395        assert!(msg.contains("section 1"), "msg: {msg}");
396        assert!(msg.contains("paragraph 5"), "msg: {msg}");
397    }
398
399    #[test]
400    fn empty_table_displays_location() {
401        let err =
402            ValidationError::EmptyTable { section_index: 0, paragraph_index: 2, run_index: 0 };
403        let msg = err.to_string();
404        assert!(msg.contains("no rows"), "msg: {msg}");
405    }
406
407    #[test]
408    fn empty_table_row_displays_location() {
409        let err = ValidationError::EmptyTableRow {
410            section_index: 0,
411            paragraph_index: 0,
412            run_index: 0,
413            row_index: 1,
414        };
415        let msg = err.to_string();
416        assert!(msg.contains("row 1"), "msg: {msg}");
417        assert!(msg.contains("no cells"), "msg: {msg}");
418    }
419
420    #[test]
421    fn invalid_span_displays_all_context() {
422        let err = ValidationError::InvalidSpan {
423            field: "col_span",
424            value: 0,
425            section_index: 0,
426            paragraph_index: 1,
427            run_index: 0,
428            row_index: 0,
429            cell_index: 2,
430        };
431        let msg = err.to_string();
432        assert!(msg.contains("col_span"), "msg: {msg}");
433        assert!(msg.contains("= 0"), "msg: {msg}");
434        assert!(msg.contains("cell 2"), "msg: {msg}");
435    }
436
437    #[test]
438    fn empty_text_box_displays_location() {
439        let err =
440            ValidationError::EmptyTextBox { section_index: 0, paragraph_index: 0, run_index: 1 };
441        let msg = err.to_string();
442        assert!(msg.contains("TextBox"), "msg: {msg}");
443    }
444
445    #[test]
446    fn empty_footnote_displays_location() {
447        let err =
448            ValidationError::EmptyFootnote { section_index: 0, paragraph_index: 0, run_index: 0 };
449        let msg = err.to_string();
450        assert!(msg.contains("Footnote"), "msg: {msg}");
451    }
452
453    #[test]
454    fn empty_table_cell_displays_location() {
455        let err = ValidationError::EmptyTableCell {
456            section_index: 0,
457            paragraph_index: 0,
458            run_index: 0,
459            row_index: 0,
460            cell_index: 0,
461        };
462        let msg = err.to_string();
463        assert!(msg.contains("cell"), "msg: {msg}");
464    }
465
466    // === CoreError wrapping ===
467
468    #[test]
469    fn core_error_from_validation() {
470        let ve = ValidationError::EmptyDocument;
471        let ce: CoreError = ve.into();
472        match ce {
473            CoreError::Validation(v) => assert_eq!(v, ValidationError::EmptyDocument),
474            other => panic!("expected Validation, got: {other}"),
475        }
476    }
477
478    #[test]
479    fn core_error_from_foundation() {
480        let fe =
481            FoundationError::InvalidField { field: "test".to_string(), reason: "bad".to_string() };
482        let ce: CoreError = fe.into();
483        assert!(matches!(ce, CoreError::Foundation(_)));
484    }
485
486    #[test]
487    fn core_error_invalid_structure() {
488        let ce = CoreError::InvalidStructure {
489            context: "document".to_string(),
490            reason: "circular reference".to_string(),
491        };
492        let msg = ce.to_string();
493        assert!(msg.contains("document"), "msg: {msg}");
494        assert!(msg.contains("circular"), "msg: {msg}");
495    }
496
497    // === Error codes ===
498
499    #[test]
500    fn error_codes_in_core_range() {
501        assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
502        assert_eq!(CoreErrorCode::EmptySection as u32, 2001);
503        assert_eq!(CoreErrorCode::EmptyParagraph as u32, 2002);
504        assert_eq!(CoreErrorCode::EmptyTable as u32, 2003);
505        assert_eq!(CoreErrorCode::EmptyTableRow as u32, 2004);
506        assert_eq!(CoreErrorCode::InvalidSpan as u32, 2005);
507        assert_eq!(CoreErrorCode::EmptyTextBox as u32, 2006);
508        assert_eq!(CoreErrorCode::EmptyFootnote as u32, 2007);
509        assert_eq!(CoreErrorCode::EmptyTableCell as u32, 2008);
510        assert_eq!(CoreErrorCode::InvalidStructure as u32, 2100);
511    }
512
513    #[test]
514    fn error_code_display_format() {
515        assert_eq!(CoreErrorCode::EmptyDocument.to_string(), "E2000");
516        assert_eq!(CoreErrorCode::InvalidStructure.to_string(), "E2100");
517    }
518
519    #[test]
520    fn validation_error_code_mapping() {
521        assert_eq!(ValidationError::EmptyDocument.code(), CoreErrorCode::EmptyDocument);
522        assert_eq!(
523            ValidationError::EmptySection { section_index: 0 }.code(),
524            CoreErrorCode::EmptySection
525        );
526        assert_eq!(
527            ValidationError::EmptyParagraph { section_index: 0, paragraph_index: 0 }.code(),
528            CoreErrorCode::EmptyParagraph
529        );
530    }
531
532    // === CoreResult alias ===
533
534    #[test]
535    fn core_result_alias_works() {
536        fn ok_example() -> CoreResult<i32> {
537            Ok(42)
538        }
539        fn err_example() -> CoreResult<i32> {
540            Err(ValidationError::EmptyDocument)?
541        }
542        assert_eq!(ok_example().unwrap(), 42);
543        assert!(err_example().is_err());
544    }
545
546    // === Send + Sync ===
547
548    #[test]
549    fn errors_are_send_and_sync() {
550        fn assert_send<T: Send>() {}
551        fn assert_sync<T: Sync>() {}
552        assert_send::<CoreError>();
553        assert_sync::<CoreError>();
554        assert_send::<ValidationError>();
555        assert_sync::<ValidationError>();
556    }
557
558    // === std::error::Error ===
559
560    #[test]
561    fn core_error_implements_std_error() {
562        let err = CoreError::from(ValidationError::EmptyDocument);
563        let _: &dyn std::error::Error = &err;
564    }
565
566    // === ValidationError PartialEq ===
567
568    #[test]
569    fn validation_error_eq() {
570        let a = ValidationError::EmptyDocument;
571        let b = ValidationError::EmptyDocument;
572        let c = ValidationError::EmptySection { section_index: 0 };
573        assert_eq!(a, b);
574        assert_ne!(a, c);
575    }
576}