hwpforge_blueprint/
error.rs

1//! Error types for the Blueprint crate.
2//!
3//! Blueprint errors occupy the **3000-3999** range in the HwpForge
4//! error code scheme.
5//!
6//! # Error Code Ranges
7//!
8//! | Range     | Crate       |
9//! |-----------|-------------|
10//! | 1000-1999 | Foundation  |
11//! | 2000-2999 | Core        |
12//! | 3000-3999 | Blueprint   |
13//! | 4000-4999 | Smithy-HWPX |
14
15use std::fmt;
16
17use hwpforge_foundation::error::{ErrorCode, ErrorCodeExt, FoundationError};
18
19/// Numeric error codes for Blueprint (3000-3999).
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[repr(u32)]
22pub enum BlueprintErrorCode {
23    /// YAML syntax or structure error.
24    YamlParse = 3000,
25    /// Invalid dimension string (e.g. "16px" instead of "16pt").
26    InvalidDimension = 3001,
27    /// Circular template inheritance detected.
28    CircularInheritance = 3002,
29    /// Referenced parent template not found.
30    TemplateNotFound = 3003,
31    /// Inheritance chain exceeds depth limit.
32    InheritanceDepthExceeded = 3004,
33    /// Style map is empty (no styles defined).
34    EmptyStyleMap = 3005,
35    /// Style resolution failed (missing required fields).
36    StyleResolution = 3006,
37    /// Duplicate style name in the same scope.
38    DuplicateStyleName = 3007,
39    /// Invalid percentage string.
40    InvalidPercentage = 3008,
41    /// Invalid color string.
42    InvalidColor = 3009,
43    /// Markdown mapping references unknown style.
44    InvalidMappingReference = 3010,
45    /// Invalid style name.
46    InvalidStyleName = 3011,
47}
48
49impl fmt::Display for BlueprintErrorCode {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "E{:04}", *self as u32)
52    }
53}
54
55/// The primary error type for the Blueprint crate.
56#[derive(Debug, Clone, thiserror::Error)]
57#[non_exhaustive]
58pub enum BlueprintError {
59    /// YAML parsing or structure error.
60    #[error("YAML parse error: {message}")]
61    YamlParse {
62        /// Description of the parse failure.
63        message: String,
64    },
65
66    /// Invalid dimension string.
67    #[error("invalid dimension '{value}': expected format like '16pt', '20mm', or '1in'")]
68    InvalidDimension {
69        /// The invalid input.
70        value: String,
71    },
72
73    /// Invalid percentage string.
74    #[error("invalid percentage '{value}': expected format like '160%'")]
75    InvalidPercentage {
76        /// The invalid input.
77        value: String,
78    },
79
80    /// Invalid color string.
81    #[error("invalid color '{value}': expected '#RRGGBB' format")]
82    InvalidColor {
83        /// The invalid input.
84        value: String,
85    },
86
87    /// Circular template inheritance detected.
88    #[error("circular template inheritance: {}", chain.join(" -> "))]
89    CircularInheritance {
90        /// The full chain showing the cycle.
91        chain: Vec<String>,
92    },
93
94    /// Referenced parent template not found.
95    #[error("template not found: '{name}'")]
96    TemplateNotFound {
97        /// The missing template name.
98        name: String,
99    },
100
101    /// Inheritance chain exceeds depth limit.
102    #[error("inheritance depth {depth} exceeds maximum {max}")]
103    InheritanceDepthExceeded {
104        /// Actual depth reached.
105        depth: usize,
106        /// Configured maximum.
107        max: usize,
108    },
109
110    /// Style map is empty.
111    #[error("template has no styles defined")]
112    EmptyStyleMap,
113
114    /// A style could not be fully resolved (missing required fields).
115    #[error("cannot resolve style '{style_name}': missing required field '{field}'")]
116    StyleResolution {
117        /// Name of the unresolvable style.
118        style_name: String,
119        /// The missing field.
120        field: String,
121    },
122
123    /// Duplicate style name.
124    #[error("duplicate style name '{name}'")]
125    DuplicateStyleName {
126        /// The duplicated name.
127        name: String,
128    },
129
130    /// Markdown mapping references a non-existent style.
131    #[error("markdown mapping '{mapping_field}' references unknown style '{style_name}'")]
132    InvalidMappingReference {
133        /// The markdown element field (e.g. "heading1").
134        mapping_field: String,
135        /// The style name that was referenced but not found.
136        style_name: String,
137    },
138
139    /// Invalid style name.
140    #[error("invalid style name '{name}': {reason}")]
141    InvalidStyleName {
142        /// The invalid name.
143        name: String,
144        /// Why it's invalid.
145        reason: String,
146    },
147
148    /// Propagated Foundation error.
149    #[error(transparent)]
150    Foundation(#[from] FoundationError),
151}
152
153impl ErrorCodeExt for BlueprintError {
154    fn code(&self) -> ErrorCode {
155        // Blueprint uses its own BlueprintErrorCode internally,
156        // but maps to Foundation's ErrorCode for cross-crate compatibility.
157        // Since Foundation's ErrorCode doesn't cover Blueprint ranges,
158        // we map to the closest generic code.
159        match self {
160            Self::Foundation(e) => e.code(),
161            Self::InvalidDimension { .. } | Self::InvalidPercentage { .. } => {
162                ErrorCode::InvalidField
163            }
164            Self::InvalidColor { .. } => ErrorCode::InvalidColor,
165            _ => ErrorCode::InvalidField,
166        }
167    }
168}
169
170impl BlueprintError {
171    /// Returns the Blueprint-specific error code.
172    pub fn blueprint_code(&self) -> BlueprintErrorCode {
173        match self {
174            Self::YamlParse { .. } => BlueprintErrorCode::YamlParse,
175            Self::InvalidDimension { .. } => BlueprintErrorCode::InvalidDimension,
176            Self::InvalidPercentage { .. } => BlueprintErrorCode::InvalidPercentage,
177            Self::InvalidColor { .. } => BlueprintErrorCode::InvalidColor,
178            Self::CircularInheritance { .. } => BlueprintErrorCode::CircularInheritance,
179            Self::TemplateNotFound { .. } => BlueprintErrorCode::TemplateNotFound,
180            Self::InheritanceDepthExceeded { .. } => BlueprintErrorCode::InheritanceDepthExceeded,
181            Self::EmptyStyleMap => BlueprintErrorCode::EmptyStyleMap,
182            Self::StyleResolution { .. } => BlueprintErrorCode::StyleResolution,
183            Self::DuplicateStyleName { .. } => BlueprintErrorCode::DuplicateStyleName,
184            Self::InvalidMappingReference { .. } => BlueprintErrorCode::InvalidMappingReference,
185            Self::InvalidStyleName { .. } => BlueprintErrorCode::InvalidStyleName,
186            Self::Foundation(_) => BlueprintErrorCode::YamlParse, // fallback
187        }
188    }
189}
190
191/// Convenience type alias for Blueprint operations.
192pub type BlueprintResult<T> = Result<T, BlueprintError>;
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn error_code_display_format() {
200        assert_eq!(BlueprintErrorCode::YamlParse.to_string(), "E3000");
201        assert_eq!(BlueprintErrorCode::InvalidDimension.to_string(), "E3001");
202        assert_eq!(BlueprintErrorCode::CircularInheritance.to_string(), "E3002");
203        assert_eq!(BlueprintErrorCode::InvalidColor.to_string(), "E3009");
204    }
205
206    #[test]
207    fn error_code_range_is_3000() {
208        assert_eq!(BlueprintErrorCode::YamlParse as u32, 3000);
209        assert_eq!(BlueprintErrorCode::InvalidColor as u32, 3009);
210    }
211
212    #[test]
213    fn yaml_parse_error_message() {
214        let err = BlueprintError::YamlParse { message: "unexpected key 'foo'".into() };
215        assert_eq!(err.to_string(), "YAML parse error: unexpected key 'foo'");
216        assert_eq!(err.blueprint_code(), BlueprintErrorCode::YamlParse);
217    }
218
219    #[test]
220    fn invalid_dimension_error_message() {
221        let err = BlueprintError::InvalidDimension { value: "16px".into() };
222        assert!(err.to_string().contains("16px"));
223        assert!(err.to_string().contains("16pt"));
224    }
225
226    #[test]
227    fn circular_inheritance_shows_chain() {
228        let err =
229            BlueprintError::CircularInheritance { chain: vec!["a".into(), "b".into(), "a".into()] };
230        assert_eq!(err.to_string(), "circular template inheritance: a -> b -> a");
231    }
232
233    #[test]
234    fn template_not_found_message() {
235        let err = BlueprintError::TemplateNotFound { name: "missing_template".into() };
236        assert!(err.to_string().contains("missing_template"));
237    }
238
239    #[test]
240    fn inheritance_depth_exceeded_message() {
241        let err = BlueprintError::InheritanceDepthExceeded { depth: 15, max: 10 };
242        assert!(err.to_string().contains("15"));
243        assert!(err.to_string().contains("10"));
244    }
245
246    #[test]
247    fn style_resolution_error_message() {
248        let err =
249            BlueprintError::StyleResolution { style_name: "heading1".into(), field: "font".into() };
250        assert!(err.to_string().contains("heading1"));
251        assert!(err.to_string().contains("font"));
252    }
253
254    #[test]
255    fn foundation_error_propagation() {
256        let foundation_err = FoundationError::EmptyIdentifier { item: "FontId".into() };
257        let err: BlueprintError = foundation_err.into();
258        assert!(matches!(err, BlueprintError::Foundation(_)));
259        assert!(err.to_string().contains("FontId"));
260    }
261
262    #[test]
263    fn error_is_send_sync() {
264        fn assert_send_sync<T: Send + Sync>() {}
265        assert_send_sync::<BlueprintError>();
266    }
267
268    #[test]
269    fn error_implements_std_error() {
270        fn assert_error<T: std::error::Error>() {}
271        assert_error::<BlueprintError>();
272    }
273
274    #[test]
275    fn blueprint_code_mapping() {
276        let cases: Vec<(BlueprintError, BlueprintErrorCode)> = vec![
277            (BlueprintError::YamlParse { message: String::new() }, BlueprintErrorCode::YamlParse),
278            (
279                BlueprintError::InvalidDimension { value: String::new() },
280                BlueprintErrorCode::InvalidDimension,
281            ),
282            (
283                BlueprintError::InvalidPercentage { value: String::new() },
284                BlueprintErrorCode::InvalidPercentage,
285            ),
286            (
287                BlueprintError::InvalidColor { value: String::new() },
288                BlueprintErrorCode::InvalidColor,
289            ),
290            (
291                BlueprintError::CircularInheritance { chain: vec![] },
292                BlueprintErrorCode::CircularInheritance,
293            ),
294            (
295                BlueprintError::TemplateNotFound { name: String::new() },
296                BlueprintErrorCode::TemplateNotFound,
297            ),
298            (
299                BlueprintError::InheritanceDepthExceeded { depth: 0, max: 0 },
300                BlueprintErrorCode::InheritanceDepthExceeded,
301            ),
302            (BlueprintError::EmptyStyleMap, BlueprintErrorCode::EmptyStyleMap),
303            (
304                BlueprintError::StyleResolution { style_name: String::new(), field: String::new() },
305                BlueprintErrorCode::StyleResolution,
306            ),
307            (
308                BlueprintError::DuplicateStyleName { name: String::new() },
309                BlueprintErrorCode::DuplicateStyleName,
310            ),
311            (
312                BlueprintError::InvalidMappingReference {
313                    mapping_field: String::new(),
314                    style_name: String::new(),
315                },
316                BlueprintErrorCode::InvalidMappingReference,
317            ),
318            (
319                BlueprintError::InvalidStyleName { name: String::new(), reason: String::new() },
320                BlueprintErrorCode::InvalidStyleName,
321            ),
322        ];
323
324        for (err, expected_code) in cases {
325            assert_eq!(err.blueprint_code(), expected_code);
326        }
327    }
328
329    #[test]
330    fn error_code_ext_for_foundation_passthrough() {
331        let err = BlueprintError::Foundation(FoundationError::InvalidHwpUnit {
332            value: 999_999_999,
333            min: -100_000_000,
334            max: 100_000_000,
335        });
336        assert_eq!(err.code(), ErrorCode::InvalidHwpUnit);
337    }
338}