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//! ## Document Anchors
20//!
21//! A [`Diagnostic`] can carry two independent "where" anchors, both optional:
22//!
23//! - [`Diagnostic::location`] — a source-text anchor (`file:line:column`).
24//!   Produced by parsers and backend compilers that operate on raw text.
25//! - [`Diagnostic::path`] — a document-model anchor pointing into the typed
26//!   [`crate::document::Document`]. Produced by schema validation and
27//!   coercion, which run on the typed model after line spans are gone.
28//!
29//! ### Path grammar
30//!
31//! ```text
32//! path        := segment ( "." field_name | "[" index "]" )*
33//! field_name  := [a-z_][a-z0-9_]*       // same charset enforced for fields/tags
34//! index       := [0-9]+
35//! ```
36//!
37//! Because field and leaf tag names are validated to that charset (no `.`,
38//! `[`, `]`, or whitespace can appear in any segment), the dotted form
39//! round-trips unambiguously.
40//!
41//! ### Path conventions
42//!
43//! | Anchor                        | Path                                          |
44//! |-------------------------------|-----------------------------------------------|
45//! | Main frontmatter field        | `title`                                       |
46//! | Nested in array of objects    | `recipients[0].name`                          |
47//! | Main leaf body                | `main.body`                                   |
48//! | Typed leaf (whole)            | `leaves.indorsement[0]`                        |
49//! | Field on typed leaf           | `leaves.indorsement[0].signature_block`        |
50//! | Body on typed leaf            | `leaves.indorsement[0].body`                   |
51//! | Leaf with unknown tag         | `leaves[0]`                                    |
52//!
53//! The `leaves.<tag>[<index>]` form fuses the leaf tag and the document
54//! array index into one segment so consumers receive both pieces of
55//! information without a second lookup.
56//!
57//! ## Error Hierarchy
58//!
59//! ### RenderError Variants
60//!
61//! - [`RenderError::EngineCreation`]: Failed to create rendering engine
62//! - [`RenderError::InvalidFrontmatter`]: Malformed YAML frontmatter
63//! - [`RenderError::CompilationFailed`]: Backend compilation errors
64//! - [`RenderError::FormatNotSupported`]: Requested format not supported
65//! - [`RenderError::UnsupportedBackend`]: Backend not registered
66//! - [`RenderError::ValidationFailed`]: Field coercion/validation failure
67//! - [`RenderError::QuillConfig`]: Quill configuration error
68//!
69//! ## Examples
70//!
71//! ### Creating Diagnostics
72//!
73//! ```
74//! use quillmark_core::{Diagnostic, Location, Severity};
75//!
76//! let diag = Diagnostic::new(Severity::Error, "Undefined variable".to_string())
77//!     .with_code("E001".to_string())
78//!     .with_location(Location {
79//!         file: "template.typ".to_string(),
80//!         line: 10,
81//!         column: 5,
82//!     })
83//!     .with_hint("Check variable spelling".to_string());
84//!
85//! println!("{}", diag.fmt_pretty());
86//! ```
87//!
88//! Example output:
89//! ```text
90//! [ERROR] Undefined variable (E001) at template.typ:10:5
91//!   hint: Check variable spelling
92//! ```
93//!
94//! ### Result with Warnings
95//!
96//! ```no_run
97//! # use quillmark_core::{RenderResult, Diagnostic, Severity, OutputFormat};
98//! # let artifacts = vec![];
99//! let result = RenderResult::new(artifacts, OutputFormat::Pdf)
100//!     .with_warning(Diagnostic::new(
101//!         Severity::Warning,
102//!         "Deprecated field used".to_string(),
103//!     ));
104//! ```
105//!
106//! ## Pretty Printing
107//!
108//! The [`Diagnostic`] type provides [`Diagnostic::fmt_pretty()`] for human-readable output with error code, location, and hints.
109//!
110//! ## Machine-Readable Output
111//!
112//! All diagnostic types implement `serde::Serialize` for JSON export:
113//!
114//! ```no_run
115//! # use quillmark_core::{Diagnostic, Severity};
116//! # let diagnostic = Diagnostic::new(Severity::Error, "Test".to_string());
117//! let json = serde_json::to_string(&diagnostic).unwrap();
118//! ```
119
120use crate::OutputFormat;
121
122/// Maximum input size for markdown (10 MB)
123pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
124
125/// Maximum YAML size (1 MB)
126pub const MAX_YAML_SIZE: usize = 1024 * 1024;
127
128/// Maximum nesting depth for markdown structures (100 levels)
129pub const MAX_NESTING_DEPTH: usize = 100;
130
131/// Maximum YAML nesting depth (100 levels)
132/// Prevents stack overflow from deeply nested YAML structures
133///
134/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
135pub use crate::document::limits::MAX_YAML_DEPTH;
136
137/// Maximum number of KIND blocks allowed per document
138/// Prevents memory exhaustion from documents with excessive leaf blocks
139pub const MAX_LEAF_COUNT: usize = 1000;
140
141/// Maximum number of fields allowed per document
142/// Prevents memory exhaustion from documents with excessive fields
143pub const MAX_FIELD_COUNT: usize = 1000;
144
145/// Error severity levels
146#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
147#[serde(rename_all = "lowercase")]
148pub enum Severity {
149    /// Fatal error that prevents completion
150    Error,
151    /// Non-fatal issue that may need attention
152    Warning,
153    /// Informational message
154    Note,
155}
156
157/// Location information for diagnostics
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct Location {
161    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
162    pub file: String,
163    /// Line number (1-indexed)
164    pub line: u32,
165    /// Column number (1-indexed)
166    pub column: u32,
167}
168
169/// Structured diagnostic information.
170///
171/// `source_chain` is a flat list of error messages from any attached
172/// `std::error::Error` cause chain, eagerly walked at construction time so
173/// the diagnostic remains trivially `Clone` and fully serializable across
174/// every binding boundary.
175#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct Diagnostic {
178    /// Error severity level
179    pub severity: Severity,
180    /// Optional error code (e.g., "E001", "typst::syntax")
181    #[serde(skip_serializing_if = "Option::is_none", default)]
182    pub code: Option<String>,
183    /// Human-readable error message
184    pub message: String,
185    /// Primary source location (text anchor: file/line/column).
186    ///
187    /// Set by parsers and backend compilers. May co-exist with [`Self::path`]
188    /// — the two anchors are independent.
189    #[serde(skip_serializing_if = "Option::is_none", default)]
190    pub location: Option<Location>,
191    /// Document-model anchor — a dotted/bracketed path into the typed
192    /// [`crate::document::Document`].
193    ///
194    /// Set by schema validation and coercion. See the module-level docs for
195    /// the path grammar and conventions. May co-exist with [`Self::location`].
196    #[serde(skip_serializing_if = "Option::is_none", default)]
197    pub path: Option<String>,
198    /// Optional hint for fixing the error
199    #[serde(skip_serializing_if = "Option::is_none", default)]
200    pub hint: Option<String>,
201    /// Flattened cause chain (outermost first).
202    #[serde(skip_serializing_if = "Vec::is_empty", default)]
203    pub source_chain: Vec<String>,
204}
205
206impl Diagnostic {
207    /// Create a new diagnostic
208    pub fn new(severity: Severity, message: String) -> Self {
209        Self {
210            severity,
211            code: None,
212            message,
213            location: None,
214            path: None,
215            hint: None,
216            source_chain: Vec::new(),
217        }
218    }
219
220    /// Set the error code
221    pub fn with_code(mut self, code: String) -> Self {
222        self.code = Some(code);
223        self
224    }
225
226    /// Set the primary location
227    pub fn with_location(mut self, location: Location) -> Self {
228        self.location = Some(location);
229        self
230    }
231
232    /// Set the document-model path anchor.
233    ///
234    /// See the module-level docs for the path grammar and conventions.
235    pub fn with_path(mut self, path: String) -> Self {
236        self.path = Some(path);
237        self
238    }
239
240    /// Set a hint
241    pub fn with_hint(mut self, hint: String) -> Self {
242        self.hint = Some(hint);
243        self
244    }
245
246    /// Attach an error cause chain, walked eagerly into `source_chain`.
247    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
248        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
249        while let Some(err) = current {
250            self.source_chain.push(err.to_string());
251            current = err.source();
252        }
253        self
254    }
255
256    /// Format diagnostic for pretty printing
257    pub fn fmt_pretty(&self) -> String {
258        let mut result = format!(
259            "[{}] {}",
260            match self.severity {
261                Severity::Error => "ERROR",
262                Severity::Warning => "WARN",
263                Severity::Note => "NOTE",
264            },
265            self.message
266        );
267
268        if let Some(ref code) = self.code {
269            result.push_str(&format!(" ({})", code));
270        }
271
272        if let Some(ref loc) = self.location {
273            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
274        }
275
276        if let Some(ref path) = self.path {
277            result.push_str(&format!("\n  at {}", path));
278        }
279
280        if let Some(ref hint) = self.hint {
281            result.push_str(&format!("\n  hint: {}", hint));
282        }
283
284        result
285    }
286
287    /// Format diagnostic with source chain for debugging
288    pub fn fmt_pretty_with_source(&self) -> String {
289        let mut result = self.fmt_pretty();
290
291        for (i, cause) in self.source_chain.iter().enumerate() {
292            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
293        }
294
295        result
296    }
297}
298
299impl std::fmt::Display for Diagnostic {
300    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301        write!(f, "{}", self.message)
302    }
303}
304
305/// Error type for parsing operations
306#[derive(thiserror::Error, Debug)]
307pub enum ParseError {
308    /// Input too large
309    #[error("Input too large: {size} bytes (max: {max} bytes)")]
310    InputTooLarge {
311        /// Actual size
312        size: usize,
313        /// Maximum allowed size
314        max: usize,
315    },
316
317    /// Invalid YAML structure
318    #[error("Invalid YAML structure: {0}")]
319    InvalidStructure(String),
320
321    /// Markdown input was empty or whitespace-only.
322    ///
323    /// Emitted as code `parse::empty_input` so consumers can pattern-match
324    /// without inspecting the message text.
325    #[error("{0}")]
326    EmptyInput(String),
327
328    /// Frontmatter is missing the required `QUILL:` field.
329    ///
330    /// Emitted as code `parse::missing_quill_field` so consumers can
331    /// pattern-match without inspecting the message text.
332    #[error("{0}")]
333    MissingQuillField(String),
334
335    /// YAML parsing error with location context
336    #[error("YAML error at line {line}: {message}")]
337    YamlErrorWithLocation {
338        /// Error message
339        message: String,
340        /// Line number in the source document (1-indexed)
341        line: usize,
342        /// Index of the metadata block (0-indexed)
343        block_index: usize,
344    },
345
346    /// Other parsing errors
347    #[error("{0}")]
348    Other(String),
349}
350
351impl ParseError {
352    /// Convert the parse error into a structured diagnostic
353    pub fn to_diagnostic(&self) -> Diagnostic {
354        match self {
355            ParseError::InputTooLarge { size, max } => Diagnostic::new(
356                Severity::Error,
357                format!("Input too large: {} bytes (max: {} bytes)", size, max),
358            )
359            .with_code("parse::input_too_large".to_string()),
360            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
361                .with_code("parse::invalid_structure".to_string()),
362            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
363                .with_code("parse::empty_input".to_string()),
364            ParseError::MissingQuillField(msg) => Diagnostic::new(Severity::Error, msg.clone())
365                .with_code("parse::missing_quill_field".to_string()),
366            ParseError::YamlErrorWithLocation {
367                message,
368                line,
369                block_index,
370            } => Diagnostic::new(
371                Severity::Error,
372                format!(
373                    "YAML error at line {} (block {}): {}",
374                    line, block_index, message
375                ),
376            )
377            .with_code("parse::yaml_error_with_location".to_string()),
378            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
379        }
380    }
381}
382
383impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
384    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
385        ParseError::Other(err.to_string())
386    }
387}
388
389impl From<String> for ParseError {
390    fn from(msg: String) -> Self {
391        ParseError::Other(msg)
392    }
393}
394
395impl From<&str> for ParseError {
396    fn from(msg: &str) -> Self {
397        ParseError::Other(msg.to_string())
398    }
399}
400
401/// Main error type for rendering operations.
402#[derive(thiserror::Error, Debug)]
403pub enum RenderError {
404    /// Failed to create rendering engine
405    #[error("{diag}")]
406    EngineCreation {
407        /// Diagnostic information
408        diag: Box<Diagnostic>,
409    },
410
411    /// Invalid YAML frontmatter in markdown document
412    #[error("{diag}")]
413    InvalidFrontmatter {
414        /// Diagnostic information
415        diag: Box<Diagnostic>,
416    },
417
418    /// Backend compilation failed with one or more errors
419    #[error("Backend compilation failed with {} error(s)", diags.len())]
420    CompilationFailed {
421        /// List of diagnostics
422        diags: Vec<Diagnostic>,
423    },
424
425    /// Requested output format not supported by backend
426    #[error("{diag}")]
427    FormatNotSupported {
428        /// Diagnostic information
429        diag: Box<Diagnostic>,
430    },
431
432    /// Backend not registered with engine
433    #[error("{diag}")]
434    UnsupportedBackend {
435        /// Diagnostic information
436        diag: Box<Diagnostic>,
437    },
438
439    /// Validation failed for parsed document — may carry multiple diagnostics
440    /// when several problems are detected during a single validation pass
441    /// (e.g. multiple missing required fields). Each diagnostic should set
442    /// `path` to anchor the error at a specific location in the document model.
443    #[error("Validation failed with {} error(s)", diags.len())]
444    ValidationFailed {
445        /// All validation diagnostics. Always non-empty.
446        diags: Vec<Diagnostic>,
447    },
448
449    /// Quill configuration error — may carry multiple diagnostics when several
450    /// problems are detected during parsing (e.g. several unknown keys at once).
451    #[error("Quill configuration failed with {} error(s)", diags.len())]
452    QuillConfig {
453        /// All configuration diagnostics. Always non-empty.
454        diags: Vec<Diagnostic>,
455    },
456}
457
458impl RenderError {
459    /// Extract all diagnostics from this error
460    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
461        match self {
462            RenderError::CompilationFailed { diags }
463            | RenderError::QuillConfig { diags }
464            | RenderError::ValidationFailed { diags } => diags.iter().collect(),
465            RenderError::EngineCreation { diag }
466            | RenderError::InvalidFrontmatter { diag }
467            | RenderError::FormatNotSupported { diag }
468            | RenderError::UnsupportedBackend { diag } => vec![diag.as_ref()],
469        }
470    }
471}
472
473/// Convert ParseError to RenderError
474impl From<ParseError> for RenderError {
475    fn from(err: ParseError) -> Self {
476        RenderError::InvalidFrontmatter {
477            diag: Box::new(
478                Diagnostic::new(Severity::Error, err.to_string())
479                    .with_code("parse::error".to_string()),
480            ),
481        }
482    }
483}
484
485/// Result type containing artifacts and warnings
486#[derive(Debug)]
487pub struct RenderResult {
488    /// Generated output artifacts
489    pub artifacts: Vec<crate::Artifact>,
490    /// Non-fatal diagnostic messages
491    pub warnings: Vec<Diagnostic>,
492    /// Output format that was produced
493    pub output_format: OutputFormat,
494}
495
496impl RenderResult {
497    /// Create a new result with artifacts and output format
498    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
499        Self {
500            artifacts,
501            warnings: Vec::new(),
502            output_format,
503        }
504    }
505
506    /// Add a warning to the result
507    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
508        self.warnings.push(warning);
509        self
510    }
511}
512
513/// Helper to print structured errors
514pub fn print_errors(err: &RenderError) {
515    for d in err.diagnostics() {
516        eprintln!("{}", d.fmt_pretty());
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_diagnostic_with_source_chain() {
526        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
527        let diag =
528            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
529
530        assert_eq!(diag.source_chain.len(), 1);
531        assert!(diag.source_chain[0].contains("File not found"));
532    }
533
534    #[test]
535    fn test_diagnostic_serialization() {
536        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
537            .with_code("E001".to_string())
538            .with_location(Location {
539                file: "test.typ".to_string(),
540                line: 10,
541                column: 5,
542            });
543
544        let json = serde_json::to_string(&diag).unwrap();
545        assert!(json.contains("Test error"));
546        assert!(json.contains("E001"));
547        assert!(json.contains("\"severity\":\"error\""));
548        assert!(json.contains("\"column\":5"));
549    }
550
551    #[test]
552    fn test_render_error_diagnostics_extraction() {
553        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
554        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
555
556        let err = RenderError::CompilationFailed {
557            diags: vec![diag1, diag2],
558        };
559
560        let diags = err.diagnostics();
561        assert_eq!(diags.len(), 2);
562    }
563
564    #[test]
565    fn test_diagnostic_fmt_pretty() {
566        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
567            .with_code("W001".to_string())
568            .with_location(Location {
569                file: "input.md".to_string(),
570                line: 5,
571                column: 10,
572            })
573            .with_hint("Use the new field name instead".to_string());
574
575        let output = diag.fmt_pretty();
576        assert!(output.contains("[WARN]"));
577        assert!(output.contains("Deprecated field used"));
578        assert!(output.contains("W001"));
579        assert!(output.contains("input.md:5:10"));
580        assert!(output.contains("hint:"));
581    }
582
583    #[test]
584    fn test_diagnostic_with_path() {
585        let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
586            .with_code("validation::missing_required".to_string())
587            .with_path("leaves.indorsement[0].signature_block".to_string());
588
589        assert_eq!(
590            diag.path.as_deref(),
591            Some("leaves.indorsement[0].signature_block")
592        );
593
594        let json = serde_json::to_string(&diag).unwrap();
595        assert!(json.contains("\"path\":\"leaves.indorsement[0].signature_block\""));
596
597        let pretty = diag.fmt_pretty();
598        assert!(pretty.contains("at leaves.indorsement[0].signature_block"));
599    }
600
601    #[test]
602    fn test_diagnostic_path_omitted_when_none() {
603        let diag = Diagnostic::new(Severity::Error, "No path".to_string());
604        let json = serde_json::to_string(&diag).unwrap();
605        assert!(!json.contains("\"path\""));
606    }
607
608    #[test]
609    fn test_diagnostic_fmt_pretty_with_source() {
610        let root_err = std::io::Error::other("Underlying error");
611        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
612            .with_code("E002".to_string())
613            .with_source(&root_err);
614
615        let output = diag.fmt_pretty_with_source();
616        assert!(output.contains("[ERROR]"));
617        assert!(output.contains("Top-level error"));
618        assert!(output.contains("cause 1:"));
619        assert!(output.contains("Underlying error"));
620    }
621
622    #[test]
623    fn test_render_result_with_warnings() {
624        let artifacts = vec![];
625        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
626
627        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
628
629        assert_eq!(result.warnings.len(), 1);
630        assert_eq!(result.warnings[0].message, "Test warning");
631    }
632}