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    /// Markdown input was empty or whitespace-only.
261    ///
262    /// Emitted as code `parse::empty_input` so consumers can pattern-match
263    /// without inspecting the message text.
264    #[error("{0}")]
265    EmptyInput(String),
266
267    /// Frontmatter is missing the required `QUILL:` field.
268    ///
269    /// Emitted as code `parse::missing_quill_field` so consumers can
270    /// pattern-match without inspecting the message text.
271    #[error("{0}")]
272    MissingQuillField(String),
273
274    /// YAML parsing error with location context
275    #[error("YAML error at line {line}: {message}")]
276    YamlErrorWithLocation {
277        /// Error message
278        message: String,
279        /// Line number in the source document (1-indexed)
280        line: usize,
281        /// Index of the metadata block (0-indexed)
282        block_index: usize,
283    },
284
285    /// Other parsing errors
286    #[error("{0}")]
287    Other(String),
288}
289
290impl ParseError {
291    /// Convert the parse error into a structured diagnostic
292    pub fn to_diagnostic(&self) -> Diagnostic {
293        match self {
294            ParseError::InputTooLarge { size, max } => Diagnostic::new(
295                Severity::Error,
296                format!("Input too large: {} bytes (max: {} bytes)", size, max),
297            )
298            .with_code("parse::input_too_large".to_string()),
299            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
300                .with_code("parse::invalid_structure".to_string()),
301            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
302                .with_code("parse::empty_input".to_string()),
303            ParseError::MissingQuillField(msg) => Diagnostic::new(Severity::Error, msg.clone())
304                .with_code("parse::missing_quill_field".to_string()),
305            ParseError::YamlErrorWithLocation {
306                message,
307                line,
308                block_index,
309            } => Diagnostic::new(
310                Severity::Error,
311                format!(
312                    "YAML error at line {} (block {}): {}",
313                    line, block_index, message
314                ),
315            )
316            .with_code("parse::yaml_error_with_location".to_string()),
317            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
318        }
319    }
320}
321
322impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
323    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
324        ParseError::Other(err.to_string())
325    }
326}
327
328impl From<String> for ParseError {
329    fn from(msg: String) -> Self {
330        ParseError::Other(msg)
331    }
332}
333
334impl From<&str> for ParseError {
335    fn from(msg: &str) -> Self {
336        ParseError::Other(msg.to_string())
337    }
338}
339
340/// Main error type for rendering operations.
341#[derive(thiserror::Error, Debug)]
342pub enum RenderError {
343    /// Failed to create rendering engine
344    #[error("{diag}")]
345    EngineCreation {
346        /// Diagnostic information
347        diag: Box<Diagnostic>,
348    },
349
350    /// Invalid YAML frontmatter in markdown document
351    #[error("{diag}")]
352    InvalidFrontmatter {
353        /// Diagnostic information
354        diag: Box<Diagnostic>,
355    },
356
357    /// Backend compilation failed with one or more errors
358    #[error("Backend compilation failed with {} error(s)", diags.len())]
359    CompilationFailed {
360        /// List of diagnostics
361        diags: Vec<Diagnostic>,
362    },
363
364    /// Requested output format not supported by backend
365    #[error("{diag}")]
366    FormatNotSupported {
367        /// Diagnostic information
368        diag: Box<Diagnostic>,
369    },
370
371    /// Backend not registered with engine
372    #[error("{diag}")]
373    UnsupportedBackend {
374        /// Diagnostic information
375        diag: Box<Diagnostic>,
376    },
377
378    /// Validation failed for parsed document
379    #[error("{diag}")]
380    ValidationFailed {
381        /// Diagnostic information
382        diag: Box<Diagnostic>,
383    },
384
385    /// Quill configuration error — may carry multiple diagnostics when several
386    /// problems are detected during parsing (e.g. several unknown keys at once).
387    #[error("Quill configuration failed with {} error(s)", diags.len())]
388    QuillConfig {
389        /// All configuration diagnostics. Always non-empty.
390        diags: Vec<Diagnostic>,
391    },
392}
393
394impl RenderError {
395    /// Extract all diagnostics from this error
396    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
397        match self {
398            RenderError::CompilationFailed { diags } | RenderError::QuillConfig { diags } => {
399                diags.iter().collect()
400            }
401            RenderError::EngineCreation { diag }
402            | RenderError::InvalidFrontmatter { diag }
403            | RenderError::FormatNotSupported { diag }
404            | RenderError::UnsupportedBackend { diag }
405            | RenderError::ValidationFailed { diag } => vec![diag.as_ref()],
406        }
407    }
408}
409
410/// Convert ParseError to RenderError
411impl From<ParseError> for RenderError {
412    fn from(err: ParseError) -> Self {
413        RenderError::InvalidFrontmatter {
414            diag: Box::new(
415                Diagnostic::new(Severity::Error, err.to_string())
416                    .with_code("parse::error".to_string()),
417            ),
418        }
419    }
420}
421
422/// Result type containing artifacts and warnings
423#[derive(Debug)]
424pub struct RenderResult {
425    /// Generated output artifacts
426    pub artifacts: Vec<crate::Artifact>,
427    /// Non-fatal diagnostic messages
428    pub warnings: Vec<Diagnostic>,
429    /// Output format that was produced
430    pub output_format: OutputFormat,
431}
432
433impl RenderResult {
434    /// Create a new result with artifacts and output format
435    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
436        Self {
437            artifacts,
438            warnings: Vec::new(),
439            output_format,
440        }
441    }
442
443    /// Add a warning to the result
444    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
445        self.warnings.push(warning);
446        self
447    }
448}
449
450/// Helper to print structured errors
451pub fn print_errors(err: &RenderError) {
452    for d in err.diagnostics() {
453        eprintln!("{}", d.fmt_pretty());
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn test_diagnostic_with_source_chain() {
463        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
464        let diag =
465            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
466
467        assert_eq!(diag.source_chain.len(), 1);
468        assert!(diag.source_chain[0].contains("File not found"));
469    }
470
471    #[test]
472    fn test_diagnostic_serialization() {
473        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
474            .with_code("E001".to_string())
475            .with_location(Location {
476                file: "test.typ".to_string(),
477                line: 10,
478                column: 5,
479            });
480
481        let json = serde_json::to_string(&diag).unwrap();
482        assert!(json.contains("Test error"));
483        assert!(json.contains("E001"));
484        assert!(json.contains("\"severity\":\"error\""));
485        assert!(json.contains("\"column\":5"));
486    }
487
488    #[test]
489    fn test_render_error_diagnostics_extraction() {
490        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
491        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
492
493        let err = RenderError::CompilationFailed {
494            diags: vec![diag1, diag2],
495        };
496
497        let diags = err.diagnostics();
498        assert_eq!(diags.len(), 2);
499    }
500
501    #[test]
502    fn test_diagnostic_fmt_pretty() {
503        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
504            .with_code("W001".to_string())
505            .with_location(Location {
506                file: "input.md".to_string(),
507                line: 5,
508                column: 10,
509            })
510            .with_hint("Use the new field name instead".to_string());
511
512        let output = diag.fmt_pretty();
513        assert!(output.contains("[WARN]"));
514        assert!(output.contains("Deprecated field used"));
515        assert!(output.contains("W001"));
516        assert!(output.contains("input.md:5:10"));
517        assert!(output.contains("hint:"));
518    }
519
520    #[test]
521    fn test_diagnostic_fmt_pretty_with_source() {
522        let root_err = std::io::Error::other("Underlying error");
523        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
524            .with_code("E002".to_string())
525            .with_source(&root_err);
526
527        let output = diag.fmt_pretty_with_source();
528        assert!(output.contains("[ERROR]"));
529        assert!(output.contains("Top-level error"));
530        assert!(output.contains("cause 1:"));
531        assert!(output.contains("Underlying error"));
532    }
533
534    #[test]
535    fn test_render_result_with_warnings() {
536        let artifacts = vec![];
537        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
538
539        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
540
541        assert_eq!(result.warnings.len(), 1);
542        assert_eq!(result.warnings[0].message, "Test warning");
543    }
544}