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 Group control contains a child that is not a drawing shape.
158    #[error("Group has a non-shape child (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
159    InvalidGroupChild {
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    /// A Footnote control contains zero paragraphs.
169    #[error("Footnote has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
170    EmptyFootnote {
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    /// An Endnote control contains zero paragraphs.
180    #[error("Endnote has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
181    EmptyEndnote {
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    },
189
190    /// A Polygon control has fewer than 3 vertices.
191    #[error("Polygon has invalid vertex count: {vertex_count} (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
192    InvalidPolygon {
193        /// Zero-based section index.
194        section_index: usize,
195        /// Zero-based paragraph index.
196        paragraph_index: usize,
197        /// Zero-based run index.
198        run_index: usize,
199        /// Number of vertices found.
200        vertex_count: usize,
201    },
202
203    /// A shape (Ellipse or Polygon) has zero width or height.
204    #[error("Shape {shape_type} has zero dimension (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
205    InvalidShapeDimension {
206        /// Zero-based section index.
207        section_index: usize,
208        /// Zero-based paragraph index.
209        paragraph_index: usize,
210        /// Zero-based run index.
211        run_index: usize,
212        /// Type of shape ("Ellipse" or "Polygon").
213        shape_type: &'static str,
214    },
215
216    /// A Chart control has no data series.
217    #[error("Chart has empty data (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
218    EmptyChartData {
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    /// An Equation control has an empty script.
228    #[error("Equation has empty script (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
229    EmptyEquation {
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    /// A Category chart has an empty categories list.
239    #[error("Chart has empty category labels (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
240    EmptyCategoryLabels {
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    },
248
249    /// An XY series has mismatched x/y value lengths.
250    #[error("XY series '{series_name}' has mismatched lengths: x={x_len}, y={y_len} (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
251    MismatchedSeriesLengths {
252        /// Zero-based section index.
253        section_index: usize,
254        /// Zero-based paragraph index.
255        paragraph_index: usize,
256        /// Zero-based run index.
257        run_index: usize,
258        /// Name of the offending series.
259        series_name: String,
260        /// Length of x_values.
261        x_len: usize,
262        /// Length of y_values.
263        y_len: usize,
264    },
265
266    /// A table cell contains zero paragraphs.
267    #[error("Table cell has no paragraphs (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index}, cell {cell_index})")]
268    EmptyTableCell {
269        /// Zero-based section index.
270        section_index: usize,
271        /// Zero-based paragraph index.
272        paragraph_index: usize,
273        /// Zero-based run index.
274        run_index: usize,
275        /// Zero-based row index.
276        row_index: usize,
277        /// Zero-based cell index.
278        cell_index: usize,
279    },
280
281    /// A header row appears after a non-header row.
282    #[error("Table header row is not part of the leading header block (section {section_index}, paragraph {paragraph_index}, run {run_index}, row {row_index})")]
283    NonLeadingTableHeaderRow {
284        /// Zero-based section index.
285        section_index: usize,
286        /// Zero-based paragraph index.
287        paragraph_index: usize,
288        /// Zero-based run index.
289        run_index: usize,
290        /// Zero-based row index.
291        row_index: usize,
292    },
293
294    /// A SUMMERY token (`UnknownSummary.token`) is empty or does not start with `$`.
295    /// HWPX SUMMERY `Command` strings always have the `$word` shape (Wave 12n).
296    #[error("Invalid SUMMERY token {token:?} (section {section_index}, paragraph {paragraph_index}, run {run_index})")]
297    InvalidSummaryToken {
298        /// Zero-based section index.
299        section_index: usize,
300        /// Zero-based paragraph index.
301        paragraph_index: usize,
302        /// Zero-based run index.
303        run_index: usize,
304        /// The offending token.
305        token: String,
306    },
307}
308
309// ---------------------------------------------------------------------------
310// ErrorCode integration
311// ---------------------------------------------------------------------------
312
313/// Core validation error codes (2000-2099).
314///
315/// Extends Foundation's [`ErrorCode`](hwpforge_foundation::ErrorCode) convention into the Core range.
316///
317/// # Examples
318///
319/// ```
320/// use hwpforge_core::error::CoreErrorCode;
321///
322/// assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
323/// ```
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
325#[repr(u32)]
326pub enum CoreErrorCode {
327    /// Empty document (no sections).
328    EmptyDocument = 2000,
329    /// Empty section (no paragraphs).
330    EmptySection = 2001,
331    /// Empty paragraph (no runs).
332    EmptyParagraph = 2002,
333    /// Empty table (no rows).
334    EmptyTable = 2003,
335    /// Empty table row (no cells).
336    EmptyTableRow = 2004,
337    /// Invalid span value (zero).
338    InvalidSpan = 2005,
339    /// Empty TextBox (no paragraphs).
340    EmptyTextBox = 2006,
341    /// Empty Footnote (no paragraphs).
342    EmptyFootnote = 2007,
343    /// Empty table cell (no paragraphs).
344    EmptyTableCell = 2008,
345    /// Empty Endnote (no paragraphs).
346    EmptyEndnote = 2009,
347    /// Invalid Polygon (fewer than 3 vertices).
348    InvalidPolygon = 2010,
349    /// Invalid shape dimension (zero width or height).
350    InvalidShapeDimension = 2011,
351    /// Empty Equation (empty script).
352    EmptyEquation = 2012,
353    /// Empty Chart data (no series).
354    EmptyChartData = 2013,
355    /// Empty category labels in a Category chart.
356    EmptyCategoryLabels = 2014,
357    /// Mismatched x/y value lengths in an XY series.
358    MismatchedSeriesLengths = 2015,
359    /// Non-leading table header row.
360    NonLeadingTableHeaderRow = 2016,
361    /// Invalid SUMMERY `$token` (Wave 12n).
362    InvalidSummaryToken = 2017,
363    // 2018 (was DateCodeFieldMismatch) and 2019 (was InlinePageNumberMismatch)
364    // are retired (E6 slice C); the gap is intentional — do NOT renumber.
365    /// A Group control has a non-shape child.
366    InvalidGroupChild = 2020,
367    /// Invalid document structure.
368    InvalidStructure = 2100,
369}
370
371impl std::fmt::Display for CoreErrorCode {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        write!(f, "E{:04}", *self as u32)
374    }
375}
376
377impl ValidationError {
378    /// Returns the numeric error code for this validation error.
379    pub fn code(&self) -> CoreErrorCode {
380        match self {
381            Self::EmptyDocument => CoreErrorCode::EmptyDocument,
382            Self::EmptySection { .. } => CoreErrorCode::EmptySection,
383            Self::EmptyParagraph { .. } => CoreErrorCode::EmptyParagraph,
384            Self::EmptyTable { .. } => CoreErrorCode::EmptyTable,
385            Self::EmptyTableRow { .. } => CoreErrorCode::EmptyTableRow,
386            Self::InvalidSpan { .. } => CoreErrorCode::InvalidSpan,
387            Self::NonLeadingTableHeaderRow { .. } => CoreErrorCode::NonLeadingTableHeaderRow,
388            Self::EmptyTextBox { .. } => CoreErrorCode::EmptyTextBox,
389            Self::EmptyFootnote { .. } => CoreErrorCode::EmptyFootnote,
390            Self::InvalidGroupChild { .. } => CoreErrorCode::InvalidGroupChild,
391            Self::EmptyTableCell { .. } => CoreErrorCode::EmptyTableCell,
392            Self::EmptyEndnote { .. } => CoreErrorCode::EmptyEndnote,
393            Self::InvalidPolygon { .. } => CoreErrorCode::InvalidPolygon,
394            Self::InvalidShapeDimension { .. } => CoreErrorCode::InvalidShapeDimension,
395            Self::EmptyChartData { .. } => CoreErrorCode::EmptyChartData,
396            Self::EmptyCategoryLabels { .. } => CoreErrorCode::EmptyCategoryLabels,
397            Self::MismatchedSeriesLengths { .. } => CoreErrorCode::MismatchedSeriesLengths,
398            Self::EmptyEquation { .. } => CoreErrorCode::EmptyEquation,
399            Self::InvalidSummaryToken { .. } => CoreErrorCode::InvalidSummaryToken,
400        }
401    }
402}
403
404/// Convenience type alias for Core operations.
405///
406/// # Examples
407///
408/// ```
409/// use hwpforge_core::error::CoreResult;
410///
411/// fn always_ok() -> CoreResult<i32> {
412///     Ok(42)
413/// }
414/// assert_eq!(always_ok().unwrap(), 42);
415/// ```
416pub type CoreResult<T> = Result<T, CoreError>;
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    // === Variant construction ===
423
424    #[test]
425    fn empty_document_displays_message() {
426        let err = ValidationError::EmptyDocument;
427        let msg = err.to_string();
428        assert!(msg.contains("section"), "msg: {msg}");
429        assert!(msg.contains("at least 1"), "msg: {msg}");
430    }
431
432    #[test]
433    fn empty_section_displays_index() {
434        let err = ValidationError::EmptySection { section_index: 3 };
435        let msg = err.to_string();
436        assert!(msg.contains("3"), "msg: {msg}");
437        assert!(msg.contains("no paragraphs"), "msg: {msg}");
438    }
439
440    #[test]
441    fn empty_paragraph_displays_location() {
442        let err = ValidationError::EmptyParagraph { section_index: 1, paragraph_index: 5 };
443        let msg = err.to_string();
444        assert!(msg.contains("section 1"), "msg: {msg}");
445        assert!(msg.contains("paragraph 5"), "msg: {msg}");
446    }
447
448    #[test]
449    fn empty_table_displays_location() {
450        let err =
451            ValidationError::EmptyTable { section_index: 0, paragraph_index: 2, run_index: 0 };
452        let msg = err.to_string();
453        assert!(msg.contains("no rows"), "msg: {msg}");
454    }
455
456    #[test]
457    fn empty_table_row_displays_location() {
458        let err = ValidationError::EmptyTableRow {
459            section_index: 0,
460            paragraph_index: 0,
461            run_index: 0,
462            row_index: 1,
463        };
464        let msg = err.to_string();
465        assert!(msg.contains("row 1"), "msg: {msg}");
466        assert!(msg.contains("no cells"), "msg: {msg}");
467    }
468
469    #[test]
470    fn invalid_span_displays_all_context() {
471        let err = ValidationError::InvalidSpan {
472            field: "col_span",
473            value: 0,
474            section_index: 0,
475            paragraph_index: 1,
476            run_index: 0,
477            row_index: 0,
478            cell_index: 2,
479        };
480        let msg = err.to_string();
481        assert!(msg.contains("col_span"), "msg: {msg}");
482        assert!(msg.contains("= 0"), "msg: {msg}");
483        assert!(msg.contains("cell 2"), "msg: {msg}");
484    }
485
486    #[test]
487    fn empty_text_box_displays_location() {
488        let err =
489            ValidationError::EmptyTextBox { section_index: 0, paragraph_index: 0, run_index: 1 };
490        let msg = err.to_string();
491        assert!(msg.contains("TextBox"), "msg: {msg}");
492    }
493
494    #[test]
495    fn empty_footnote_displays_location() {
496        let err =
497            ValidationError::EmptyFootnote { section_index: 0, paragraph_index: 0, run_index: 0 };
498        let msg = err.to_string();
499        assert!(msg.contains("Footnote"), "msg: {msg}");
500    }
501
502    #[test]
503    fn empty_table_cell_displays_location() {
504        let err = ValidationError::EmptyTableCell {
505            section_index: 0,
506            paragraph_index: 0,
507            run_index: 0,
508            row_index: 0,
509            cell_index: 0,
510        };
511        let msg = err.to_string();
512        assert!(msg.contains("cell"), "msg: {msg}");
513    }
514
515    // === CoreError wrapping ===
516
517    #[test]
518    fn core_error_from_validation() {
519        let ve = ValidationError::EmptyDocument;
520        let ce: CoreError = ve.into();
521        match ce {
522            CoreError::Validation(v) => assert_eq!(v, ValidationError::EmptyDocument),
523            other => panic!("expected Validation, got: {other}"),
524        }
525    }
526
527    #[test]
528    fn core_error_from_foundation() {
529        let fe =
530            FoundationError::InvalidField { field: "test".to_string(), reason: "bad".to_string() };
531        let ce: CoreError = fe.into();
532        assert!(matches!(ce, CoreError::Foundation(_)));
533    }
534
535    #[test]
536    fn core_error_invalid_structure() {
537        let ce = CoreError::InvalidStructure {
538            context: "document".to_string(),
539            reason: "circular reference".to_string(),
540        };
541        let msg = ce.to_string();
542        assert!(msg.contains("document"), "msg: {msg}");
543        assert!(msg.contains("circular"), "msg: {msg}");
544    }
545
546    // === Error codes ===
547
548    #[test]
549    fn error_codes_in_core_range() {
550        assert_eq!(CoreErrorCode::EmptyDocument as u32, 2000);
551        assert_eq!(CoreErrorCode::EmptySection as u32, 2001);
552        assert_eq!(CoreErrorCode::EmptyParagraph as u32, 2002);
553        assert_eq!(CoreErrorCode::EmptyTable as u32, 2003);
554        assert_eq!(CoreErrorCode::EmptyTableRow as u32, 2004);
555        assert_eq!(CoreErrorCode::InvalidSpan as u32, 2005);
556        assert_eq!(CoreErrorCode::EmptyTextBox as u32, 2006);
557        assert_eq!(CoreErrorCode::EmptyFootnote as u32, 2007);
558        assert_eq!(CoreErrorCode::EmptyTableCell as u32, 2008);
559        assert_eq!(CoreErrorCode::InvalidStructure as u32, 2100);
560    }
561
562    #[test]
563    fn error_code_display_format() {
564        assert_eq!(CoreErrorCode::EmptyDocument.to_string(), "E2000");
565        assert_eq!(CoreErrorCode::InvalidStructure.to_string(), "E2100");
566    }
567
568    #[test]
569    fn validation_error_code_mapping() {
570        assert_eq!(ValidationError::EmptyDocument.code(), CoreErrorCode::EmptyDocument);
571        assert_eq!(
572            ValidationError::EmptySection { section_index: 0 }.code(),
573            CoreErrorCode::EmptySection
574        );
575        assert_eq!(
576            ValidationError::EmptyParagraph { section_index: 0, paragraph_index: 0 }.code(),
577            CoreErrorCode::EmptyParagraph
578        );
579    }
580
581    // === CoreResult alias ===
582
583    #[test]
584    fn core_result_alias_works() {
585        fn ok_example() -> CoreResult<i32> {
586            Ok(42)
587        }
588        fn err_example() -> CoreResult<i32> {
589            Err(ValidationError::EmptyDocument)?
590        }
591        assert_eq!(ok_example().unwrap(), 42);
592        assert!(err_example().is_err());
593    }
594
595    // === Send + Sync ===
596
597    #[test]
598    fn errors_are_send_and_sync() {
599        fn assert_send<T: Send>() {}
600        fn assert_sync<T: Sync>() {}
601        assert_send::<CoreError>();
602        assert_sync::<CoreError>();
603        assert_send::<ValidationError>();
604        assert_sync::<ValidationError>();
605    }
606
607    // === std::error::Error ===
608
609    #[test]
610    fn core_error_implements_std_error() {
611        let err = CoreError::from(ValidationError::EmptyDocument);
612        let _: &dyn std::error::Error = &err;
613    }
614
615    // === ValidationError PartialEq ===
616
617    #[test]
618    fn validation_error_eq() {
619        let a = ValidationError::EmptyDocument;
620        let b = ValidationError::EmptyDocument;
621        let c = ValidationError::EmptySection { section_index: 0 };
622        assert_eq!(a, b);
623        assert_ne!(a, c);
624    }
625}