Skip to main content

quillmark_core/
error.rs

1//! # Error Handling
2//!
3//! Structured error handling with diagnostics and source location tracking.
4//!
5//! ## Overview
6//!
7//! The `error` module provides error types and diagnostic types for actionable
8//! error reporting with source location tracking.
9//!
10//! ## Key Types
11//!
12//! - [`RenderError`]: Main error enum for rendering operations
13
14//! - [`Diagnostic`]: Structured diagnostic information
15//! - [`Location`]: Source file location (file, line, column)
16//! - [`Severity`]: Error severity levels (Error, Warning, Note)
17//! - [`RenderResult`]: Result type with artifacts and warnings
18//!
19//! ## Error Hierarchy
20//!
21//! ### RenderError Variants
22//!
23//! - [`RenderError::EngineCreation`]: Failed to create rendering engine
24//! - [`RenderError::InvalidFrontmatter`]: Malformed YAML frontmatter
25//! - [`RenderError::CompilationFailed`]: Backend compilation errors
26//! - [`RenderError::FormatNotSupported`]: Requested format not supported
27//! - [`RenderError::UnsupportedBackend`]: Backend not registered
28//! - [`RenderError::ValidationFailed`]: Field coercion/validation failure
29//! - [`RenderError::QuillConfig`]: Quill configuration error
30//!
31//! ## Examples
32//!
33//! ### Creating Diagnostics
34//!
35//! ```
36//! use quillmark_core::{Diagnostic, Location, Severity};
37//!
38//! let diag = Diagnostic::new(Severity::Error, "Undefined variable".to_string())
39//!     .with_code("E001".to_string())
40//!     .with_location(Location {
41//!         file: "template.typ".to_string(),
42//!         line: 10,
43//!         column: 5,
44//!     })
45//!     .with_hint("Check variable spelling".to_string());
46//!
47//! println!("{}", diag.fmt_pretty());
48//! ```
49//!
50//! Example output:
51//! ```text
52//! [ERROR] Undefined variable (E001) at template.typ:10:5
53//!   hint: Check variable spelling
54//! ```
55//!
56//! ### Result with Warnings
57//!
58//! ```no_run
59//! # use quillmark_core::{RenderResult, Diagnostic, Severity, OutputFormat};
60//! # let artifacts = vec![];
61//! let result = RenderResult::new(artifacts, OutputFormat::Pdf)
62//!     .with_warning(Diagnostic::new(
63//!         Severity::Warning,
64//!         "Deprecated field used".to_string(),
65//!     ));
66//! ```
67//!
68//! ## Pretty Printing
69//!
70//! The [`Diagnostic`] type provides [`Diagnostic::fmt_pretty()`] for human-readable output with error code, location, and hints.
71//!
72//! ## Machine-Readable Output
73//!
74//! All diagnostic types implement `serde::Serialize` for JSON export:
75//!
76//! ```no_run
77//! # use quillmark_core::{Diagnostic, Severity};
78//! # let diagnostic = Diagnostic::new(Severity::Error, "Test".to_string());
79//! let json = serde_json::to_string(&diagnostic).unwrap();
80//! ```
81
82use crate::OutputFormat;
83
84/// Maximum input size for markdown (10 MB)
85pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
86
87/// Maximum YAML size (1 MB)
88pub const MAX_YAML_SIZE: usize = 1024 * 1024;
89
90/// Maximum nesting depth for markdown structures (100 levels)
91pub const MAX_NESTING_DEPTH: usize = 100;
92
93/// Maximum YAML nesting depth (100 levels)
94/// Prevents stack overflow from deeply nested YAML structures
95///
96/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
97pub use crate::document::limits::MAX_YAML_DEPTH;
98
99/// Maximum number of CARD blocks allowed per document
100/// Prevents memory exhaustion from documents with excessive card blocks
101pub const MAX_CARD_COUNT: usize = 1000;
102
103/// Maximum number of fields allowed per document
104/// Prevents memory exhaustion from documents with excessive fields
105pub const MAX_FIELD_COUNT: usize = 1000;
106
107/// Error severity levels
108#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum Severity {
111    /// Fatal error that prevents completion
112    Error,
113    /// Non-fatal issue that may need attention
114    Warning,
115    /// Informational message
116    Note,
117}
118
119/// Location information for diagnostics
120#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
121#[serde(rename_all = "camelCase")]
122pub struct Location {
123    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
124    pub file: String,
125    /// Line number (1-indexed)
126    pub line: u32,
127    /// Column number (1-indexed)
128    pub column: u32,
129}
130
131/// Structured diagnostic information.
132///
133/// `source_chain` is a flat list of error messages from any attached
134/// `std::error::Error` cause chain, eagerly walked at construction time so
135/// the diagnostic remains trivially `Clone` and fully serializable across
136/// every binding boundary.
137#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
138#[serde(rename_all = "camelCase")]
139pub struct Diagnostic {
140    /// Error severity level
141    pub severity: Severity,
142    /// Optional error code (e.g., "E001", "typst::syntax")
143    #[serde(skip_serializing_if = "Option::is_none", default)]
144    pub code: Option<String>,
145    /// Human-readable error message
146    pub message: String,
147    /// Primary source location
148    #[serde(skip_serializing_if = "Option::is_none", default)]
149    pub location: Option<Location>,
150    /// Optional hint for fixing the error
151    #[serde(skip_serializing_if = "Option::is_none", default)]
152    pub hint: Option<String>,
153    /// Flattened cause chain (outermost first).
154    #[serde(skip_serializing_if = "Vec::is_empty", default)]
155    pub source_chain: Vec<String>,
156}
157
158impl Diagnostic {
159    /// Create a new diagnostic
160    pub fn new(severity: Severity, message: String) -> Self {
161        Self {
162            severity,
163            code: None,
164            message,
165            location: None,
166            hint: None,
167            source_chain: Vec::new(),
168        }
169    }
170
171    /// Set the error code
172    pub fn with_code(mut self, code: String) -> Self {
173        self.code = Some(code);
174        self
175    }
176
177    /// Set the primary location
178    pub fn with_location(mut self, location: Location) -> Self {
179        self.location = Some(location);
180        self
181    }
182
183    /// Set a hint
184    pub fn with_hint(mut self, hint: String) -> Self {
185        self.hint = Some(hint);
186        self
187    }
188
189    /// Attach an error cause chain, walked eagerly into `source_chain`.
190    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
191        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
192        while let Some(err) = current {
193            self.source_chain.push(err.to_string());
194            current = err.source();
195        }
196        self
197    }
198
199    /// Format diagnostic for pretty printing
200    pub fn fmt_pretty(&self) -> String {
201        let mut result = format!(
202            "[{}] {}",
203            match self.severity {
204                Severity::Error => "ERROR",
205                Severity::Warning => "WARN",
206                Severity::Note => "NOTE",
207            },
208            self.message
209        );
210
211        if let Some(ref code) = self.code {
212            result.push_str(&format!(" ({})", code));
213        }
214
215        if let Some(ref loc) = self.location {
216            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
217        }
218
219        if let Some(ref hint) = self.hint {
220            result.push_str(&format!("\n  hint: {}", hint));
221        }
222
223        result
224    }
225
226    /// Format diagnostic with source chain for debugging
227    pub fn fmt_pretty_with_source(&self) -> String {
228        let mut result = self.fmt_pretty();
229
230        for (i, cause) in self.source_chain.iter().enumerate() {
231            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
232        }
233
234        result
235    }
236}
237
238impl std::fmt::Display for Diagnostic {
239    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
240        write!(f, "{}", self.message)
241    }
242}
243
244/// Error type for parsing operations
245#[derive(thiserror::Error, Debug)]
246pub enum ParseError {
247    /// Input too large
248    #[error("Input too large: {size} bytes (max: {max} bytes)")]
249    InputTooLarge {
250        /// Actual size
251        size: usize,
252        /// Maximum allowed size
253        max: usize,
254    },
255
256    /// Invalid YAML structure
257    #[error("Invalid YAML structure: {0}")]
258    InvalidStructure(String),
259
260    /// YAML parsing error with location context
261    #[error("YAML error at line {line}: {message}")]
262    YamlErrorWithLocation {
263        /// Error message
264        message: String,
265        /// Line number in the source document (1-indexed)
266        line: usize,
267        /// Index of the metadata block (0-indexed)
268        block_index: usize,
269    },
270
271    /// Other parsing errors
272    #[error("{0}")]
273    Other(String),
274}
275
276impl ParseError {
277    /// Convert the parse error into a structured diagnostic
278    pub fn to_diagnostic(&self) -> Diagnostic {
279        match self {
280            ParseError::InputTooLarge { size, max } => Diagnostic::new(
281                Severity::Error,
282                format!("Input too large: {} bytes (max: {} bytes)", size, max),
283            )
284            .with_code("parse::input_too_large".to_string()),
285            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
286                .with_code("parse::invalid_structure".to_string()),
287            ParseError::YamlErrorWithLocation {
288                message,
289                line,
290                block_index,
291            } => Diagnostic::new(
292                Severity::Error,
293                format!(
294                    "YAML error at line {} (block {}): {}",
295                    line, block_index, message
296                ),
297            )
298            .with_code("parse::yaml_error_with_location".to_string()),
299            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
300        }
301    }
302}
303
304impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
305    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
306        ParseError::Other(err.to_string())
307    }
308}
309
310impl From<String> for ParseError {
311    fn from(msg: String) -> Self {
312        ParseError::Other(msg)
313    }
314}
315
316impl From<&str> for ParseError {
317    fn from(msg: &str) -> Self {
318        ParseError::Other(msg.to_string())
319    }
320}
321
322/// Main error type for rendering operations.
323#[derive(thiserror::Error, Debug)]
324pub enum RenderError {
325    /// Failed to create rendering engine
326    #[error("{diag}")]
327    EngineCreation {
328        /// Diagnostic information
329        diag: Box<Diagnostic>,
330    },
331
332    /// Invalid YAML frontmatter in markdown document
333    #[error("{diag}")]
334    InvalidFrontmatter {
335        /// Diagnostic information
336        diag: Box<Diagnostic>,
337    },
338
339    /// Backend compilation failed with one or more errors
340    #[error("Backend compilation failed with {} error(s)", diags.len())]
341    CompilationFailed {
342        /// List of diagnostics
343        diags: Vec<Diagnostic>,
344    },
345
346    /// Requested output format not supported by backend
347    #[error("{diag}")]
348    FormatNotSupported {
349        /// Diagnostic information
350        diag: Box<Diagnostic>,
351    },
352
353    /// Backend not registered with engine
354    #[error("{diag}")]
355    UnsupportedBackend {
356        /// Diagnostic information
357        diag: Box<Diagnostic>,
358    },
359
360    /// Validation failed for parsed document
361    #[error("{diag}")]
362    ValidationFailed {
363        /// Diagnostic information
364        diag: Box<Diagnostic>,
365    },
366
367    /// Quill configuration error
368    #[error("{diag}")]
369    QuillConfig {
370        /// Diagnostic information
371        diag: Box<Diagnostic>,
372    },
373}
374
375impl RenderError {
376    /// Extract all diagnostics from this error
377    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
378        match self {
379            RenderError::CompilationFailed { diags } => diags.iter().collect(),
380            RenderError::EngineCreation { diag }
381            | RenderError::InvalidFrontmatter { diag }
382            | RenderError::FormatNotSupported { diag }
383            | RenderError::UnsupportedBackend { diag }
384            | RenderError::ValidationFailed { diag }
385            | RenderError::QuillConfig { diag } => vec![diag.as_ref()],
386        }
387    }
388}
389
390/// Convert ParseError to RenderError
391impl From<ParseError> for RenderError {
392    fn from(err: ParseError) -> Self {
393        RenderError::InvalidFrontmatter {
394            diag: Box::new(
395                Diagnostic::new(Severity::Error, err.to_string())
396                    .with_code("parse::error".to_string()),
397            ),
398        }
399    }
400}
401
402/// Result type containing artifacts and warnings
403#[derive(Debug)]
404pub struct RenderResult {
405    /// Generated output artifacts
406    pub artifacts: Vec<crate::Artifact>,
407    /// Non-fatal diagnostic messages
408    pub warnings: Vec<Diagnostic>,
409    /// Output format that was produced
410    pub output_format: OutputFormat,
411}
412
413impl RenderResult {
414    /// Create a new result with artifacts and output format
415    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
416        Self {
417            artifacts,
418            warnings: Vec::new(),
419            output_format,
420        }
421    }
422
423    /// Add a warning to the result
424    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
425        self.warnings.push(warning);
426        self
427    }
428}
429
430/// Helper to print structured errors
431pub fn print_errors(err: &RenderError) {
432    for d in err.diagnostics() {
433        eprintln!("{}", d.fmt_pretty());
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440
441    #[test]
442    fn test_diagnostic_with_source_chain() {
443        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
444        let diag =
445            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
446
447        assert_eq!(diag.source_chain.len(), 1);
448        assert!(diag.source_chain[0].contains("File not found"));
449    }
450
451    #[test]
452    fn test_diagnostic_serialization() {
453        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
454            .with_code("E001".to_string())
455            .with_location(Location {
456                file: "test.typ".to_string(),
457                line: 10,
458                column: 5,
459            });
460
461        let json = serde_json::to_string(&diag).unwrap();
462        assert!(json.contains("Test error"));
463        assert!(json.contains("E001"));
464        assert!(json.contains("\"severity\":\"error\""));
465        assert!(json.contains("\"column\":5"));
466    }
467
468    #[test]
469    fn test_render_error_diagnostics_extraction() {
470        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
471        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
472
473        let err = RenderError::CompilationFailed {
474            diags: vec![diag1, diag2],
475        };
476
477        let diags = err.diagnostics();
478        assert_eq!(diags.len(), 2);
479    }
480
481    #[test]
482    fn test_diagnostic_fmt_pretty() {
483        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
484            .with_code("W001".to_string())
485            .with_location(Location {
486                file: "input.md".to_string(),
487                line: 5,
488                column: 10,
489            })
490            .with_hint("Use the new field name instead".to_string());
491
492        let output = diag.fmt_pretty();
493        assert!(output.contains("[WARN]"));
494        assert!(output.contains("Deprecated field used"));
495        assert!(output.contains("W001"));
496        assert!(output.contains("input.md:5:10"));
497        assert!(output.contains("hint:"));
498    }
499
500    #[test]
501    fn test_diagnostic_fmt_pretty_with_source() {
502        let root_err = std::io::Error::other("Underlying error");
503        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
504            .with_code("E002".to_string())
505            .with_source(&root_err);
506
507        let output = diag.fmt_pretty_with_source();
508        assert!(output.contains("[ERROR]"));
509        assert!(output.contains("Top-level error"));
510        assert!(output.contains("cause 1:"));
511        assert!(output.contains("Underlying error"));
512    }
513
514    #[test]
515    fn test_render_result_with_warnings() {
516        let artifacts = vec![];
517        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
518
519        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
520
521        assert_eq!(result.warnings.len(), 1);
522        assert_eq!(result.warnings[0].message, "Test warning");
523    }
524}