Skip to main content

quillmark_core/
error.rs

1//! # Error Handling
2//!
3//! Error types and diagnostics for parsing and rendering, with source location tracking.
4//!
5//! ## Document path anchors
6//!
7//! A [`Diagnostic`] carries two independent "where" anchors, both optional:
8//!
9//! - [`Diagnostic::location`] — source-text anchor (`file:line:column`).
10//!   Produced by parsers and backend compilers operating on raw text.
11//! - [`Diagnostic::path`] — document-model anchor into the typed
12//!   [`crate::document::Document`]. Produced by schema validation and
13//!   coercion, which run on the typed model after line spans are gone.
14//!
15//! ### Path grammar
16//!
17//! ```text
18//! path        := segment ( "." field_name | "[" index "]" )*
19//! field_name  := [a-z_][a-z0-9_]*       // same charset enforced for fields/kinds
20//! index       := [0-9]+
21//! ```
22//!
23//! Because field names and card kinds are validated to that charset (no `.`,
24//! `[`, `]`, or whitespace), the dotted form round-trips unambiguously.
25//!
26//! | Anchor                     | Path                                      |
27//! |----------------------------|-------------------------------------------|
28//! | Root-block field           | `title`                                   |
29//! | Nested in array of objects | `recipients[0].name`                      |
30//! | Main card body             | `main.body`                               |
31//! | Typed card (whole)         | `cards.indorsement[0]`                    |
32//! | Field on typed card        | `cards.indorsement[0].signature_block`    |
33//! | Body on typed card         | `cards.indorsement[0].body`               |
34//! | Card with unknown kind     | `cards[0]`                                |
35//!
36//! The `cards.<kind>[<index>]` form fuses card kind and document array index so
37//! consumers receive both without a second lookup.
38
39use crate::OutputFormat;
40
41/// Maximum input size for markdown (10 MB)
42pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
43
44/// Maximum YAML size (1 MB)
45pub const MAX_YAML_SIZE: usize = 1024 * 1024;
46
47/// Maximum nesting depth for markdown structures (100 levels)
48pub const MAX_NESTING_DEPTH: usize = 100;
49
50/// Re-exported from [`crate::document::limits::MAX_YAML_DEPTH`].
51pub use crate::document::limits::MAX_YAML_DEPTH;
52
53/// Maximum number of card blocks allowed per document
54pub const MAX_CARD_COUNT: usize = 1000;
55
56/// Maximum number of fields allowed per document
57pub const MAX_FIELD_COUNT: usize = 1000;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Severity {
62    /// Fatal error that prevents completion
63    Error,
64    /// Non-fatal issue that may need attention
65    Warning,
66    /// Informational message
67    Note,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Location {
73    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
74    pub file: String,
75    /// Line number (1-indexed)
76    pub line: u32,
77    /// Column number (1-indexed)
78    pub column: u32,
79}
80
81/// Structured diagnostic information.
82///
83/// `source_chain` is a flat list of error messages from any attached
84/// `std::error::Error` cause chain, eagerly walked at construction time so
85/// the diagnostic remains trivially `Clone` and fully serializable across
86/// every binding boundary.
87#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct Diagnostic {
90    pub severity: Severity,
91    /// Optional error code (e.g., "E001", "typst::syntax")
92    #[serde(skip_serializing_if = "Option::is_none", default)]
93    pub code: Option<String>,
94    pub message: String,
95    /// Primary source location (text anchor: file/line/column).
96    ///
97    /// Set by parsers and backend compilers. May co-exist with [`Self::path`]
98    /// — the two anchors are independent.
99    #[serde(skip_serializing_if = "Option::is_none", default)]
100    pub location: Option<Location>,
101    /// Document-model anchor — a dotted/bracketed path into the typed
102    /// [`crate::document::Document`].
103    ///
104    /// Set by schema validation and coercion. See the module-level docs for
105    /// the path grammar and conventions. May co-exist with [`Self::location`].
106    #[serde(skip_serializing_if = "Option::is_none", default)]
107    pub path: Option<String>,
108    #[serde(skip_serializing_if = "Option::is_none", default)]
109    pub hint: Option<String>,
110    /// Flattened cause chain (outermost first).
111    #[serde(skip_serializing_if = "Vec::is_empty", default)]
112    pub source_chain: Vec<String>,
113}
114
115impl Diagnostic {
116    pub fn new(severity: Severity, message: String) -> Self {
117        Self {
118            severity,
119            code: None,
120            message,
121            location: None,
122            path: None,
123            hint: None,
124            source_chain: Vec::new(),
125        }
126    }
127
128    pub fn with_code(mut self, code: String) -> Self {
129        self.code = Some(code);
130        self
131    }
132
133    pub fn with_location(mut self, location: Location) -> Self {
134        self.location = Some(location);
135        self
136    }
137
138    /// Set the document-model path anchor.
139    ///
140    /// See the module-level docs for the path grammar and conventions.
141    pub fn with_path(mut self, path: String) -> Self {
142        self.path = Some(path);
143        self
144    }
145
146    pub fn with_hint(mut self, hint: String) -> Self {
147        self.hint = Some(hint);
148        self
149    }
150
151    /// Attach an error cause chain, walked eagerly into `source_chain`.
152    pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
153        let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
154        while let Some(err) = current {
155            self.source_chain.push(err.to_string());
156            current = err.source();
157        }
158        self
159    }
160
161    pub fn fmt_pretty(&self) -> String {
162        let mut result = format!(
163            "[{}] {}",
164            match self.severity {
165                Severity::Error => "ERROR",
166                Severity::Warning => "WARN",
167                Severity::Note => "NOTE",
168            },
169            self.message
170        );
171
172        if let Some(ref code) = self.code {
173            result.push_str(&format!(" ({})", code));
174        }
175
176        if let Some(ref loc) = self.location {
177            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.column));
178        }
179
180        if let Some(ref path) = self.path {
181            result.push_str(&format!("\n  at {}", path));
182        }
183
184        if let Some(ref hint) = self.hint {
185            result.push_str(&format!("\n  hint: {}", hint));
186        }
187
188        result
189    }
190
191    /// Format diagnostic with source chain for debugging.
192    pub fn fmt_pretty_with_source(&self) -> String {
193        let mut result = self.fmt_pretty();
194
195        for (i, cause) in self.source_chain.iter().enumerate() {
196            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
197        }
198
199        result
200    }
201}
202
203impl std::fmt::Display for Diagnostic {
204    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205        write!(f, "{}", self.message)
206    }
207}
208
209#[derive(thiserror::Error, Debug)]
210pub enum ParseError {
211    #[error("Input too large: {size} bytes (max: {max} bytes)")]
212    InputTooLarge { size: usize, max: usize },
213
214    #[error("Invalid YAML structure: {0}")]
215    InvalidStructure(String),
216
217    /// Markdown input was empty or whitespace-only.
218    ///
219    /// Emitted as code `parse::empty_input` so consumers can pattern-match
220    /// without inspecting the message text.
221    #[error("{0}")]
222    EmptyInput(String),
223
224    /// The document is missing its root `~~~card-yaml` block, or that block
225    /// does not declare the required `$quill` system metadata.
226    ///
227    /// Emitted as code `parse::missing_quill` so consumers can
228    /// pattern-match without inspecting the message text.
229    #[error("{0}")]
230    MissingQuill(String),
231
232    #[error("YAML error at line {line}: {message}")]
233    YamlErrorWithLocation {
234        message: String,
235        /// Line number in the source document (1-indexed)
236        line: usize,
237        /// Index of the metadata block (0-indexed)
238        block_index: usize,
239    },
240}
241
242impl ParseError {
243    pub fn to_diagnostic(&self) -> Diagnostic {
244        match self {
245            ParseError::InputTooLarge { size, max } => Diagnostic::new(
246                Severity::Error,
247                format!("Input too large: {} bytes (max: {} bytes)", size, max),
248            )
249            .with_code("parse::input_too_large".to_string()),
250            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
251                .with_code("parse::invalid_structure".to_string()),
252            ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
253                .with_code("parse::empty_input".to_string()),
254            ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
255                .with_code("parse::missing_quill".to_string()),
256            ParseError::YamlErrorWithLocation {
257                message,
258                line,
259                block_index,
260            } => Diagnostic::new(
261                Severity::Error,
262                format!(
263                    "YAML error at line {} (block {}): {}",
264                    line, block_index, message
265                ),
266            )
267            .with_code("parse::yaml_error_with_location".to_string()),
268        }
269    }
270}
271
272/// Main error type for rendering operations.
273///
274/// Every variant carries a non-empty `diags: Vec<Diagnostic>`. Variants whose
275/// failure is inherently a single diagnostic still carry a one-element vector
276/// — the uniform shape lets every consumer, and every language binding,
277/// handle all rendering errors through a single code path. See
278/// [`RenderError::diagnostics`] and [`RenderError::into_diagnostics`]. The
279/// variant itself records the *kind* of failure (which bindings map to typed
280/// exceptions); the payload is always just the diagnostics.
281#[derive(Debug)]
282pub enum RenderError {
283    /// Failed to create rendering engine.
284    EngineCreation {
285        /// Diagnostics describing the failure. Always non-empty.
286        diags: Vec<Diagnostic>,
287    },
288
289    /// Invalid YAML in a card-yaml block.
290    InvalidPayload {
291        /// Diagnostics describing the failure. Always non-empty.
292        diags: Vec<Diagnostic>,
293    },
294
295    /// Backend compilation failed with one or more errors.
296    CompilationFailed {
297        /// All compilation diagnostics. Always non-empty.
298        diags: Vec<Diagnostic>,
299    },
300
301    /// Requested output format not supported by backend.
302    FormatNotSupported {
303        /// Diagnostics describing the failure. Always non-empty.
304        diags: Vec<Diagnostic>,
305    },
306
307    /// Backend not registered with engine.
308    UnsupportedBackend {
309        /// Diagnostics describing the failure. Always non-empty.
310        diags: Vec<Diagnostic>,
311    },
312
313    /// Validation failed for parsed document — may carry multiple diagnostics
314    /// when several problems are detected during a single validation pass
315    /// (e.g. multiple missing required fields). Each diagnostic should set
316    /// `path` to anchor the error at a specific location in the document model.
317    ValidationFailed {
318        /// All validation diagnostics. Always non-empty.
319        diags: Vec<Diagnostic>,
320    },
321
322    /// Quill configuration error — may carry multiple diagnostics when several
323    /// problems are detected during parsing (e.g. several unknown keys at once).
324    QuillConfig {
325        /// All configuration diagnostics. Always non-empty.
326        diags: Vec<Diagnostic>,
327    },
328}
329
330impl RenderError {
331    /// Returns all diagnostics for this error. Always non-empty.
332    pub fn diagnostics(&self) -> &[Diagnostic] {
333        match self {
334            RenderError::EngineCreation { diags }
335            | RenderError::InvalidPayload { diags }
336            | RenderError::CompilationFailed { diags }
337            | RenderError::FormatNotSupported { diags }
338            | RenderError::UnsupportedBackend { diags }
339            | RenderError::ValidationFailed { diags }
340            | RenderError::QuillConfig { diags } => diags,
341        }
342    }
343
344    /// Consume the error and return its diagnostics.
345    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
346        match self {
347            RenderError::EngineCreation { diags }
348            | RenderError::InvalidPayload { diags }
349            | RenderError::CompilationFailed { diags }
350            | RenderError::FormatNotSupported { diags }
351            | RenderError::UnsupportedBackend { diags }
352            | RenderError::ValidationFailed { diags }
353            | RenderError::QuillConfig { diags } => diags,
354        }
355    }
356}
357
358impl std::fmt::Display for RenderError {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        match self {
361            RenderError::CompilationFailed { diags } => {
362                write!(
363                    f,
364                    "Backend compilation failed with {} error(s)",
365                    diags.len()
366                )
367            }
368            RenderError::ValidationFailed { diags } => {
369                write!(f, "Validation failed with {} error(s)", diags.len())
370            }
371            RenderError::QuillConfig { diags } => {
372                write!(
373                    f,
374                    "Quill configuration failed with {} error(s)",
375                    diags.len()
376                )
377            }
378            RenderError::EngineCreation { .. }
379            | RenderError::InvalidPayload { .. }
380            | RenderError::FormatNotSupported { .. }
381            | RenderError::UnsupportedBackend { .. } => match self.diagnostics().first() {
382                Some(d) => write!(f, "{}", d.message),
383                None => write!(f, "render error"),
384            },
385        }
386    }
387}
388
389impl std::error::Error for RenderError {}
390
391impl From<ParseError> for RenderError {
392    fn from(err: ParseError) -> Self {
393        RenderError::InvalidPayload {
394            diags: vec![Diagnostic::new(Severity::Error, err.to_string())
395                .with_code("parse::error".to_string())],
396        }
397    }
398}
399
400#[derive(Debug)]
401pub struct RenderResult {
402    pub artifacts: Vec<crate::Artifact>,
403    pub warnings: Vec<Diagnostic>,
404    pub output_format: OutputFormat,
405}
406
407impl RenderResult {
408    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
409        Self {
410            artifacts,
411            warnings: Vec::new(),
412            output_format,
413        }
414    }
415
416    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
417        self.warnings.push(warning);
418        self
419    }
420}
421
422pub fn print_errors(err: &RenderError) {
423    for d in err.diagnostics() {
424        eprintln!("{}", d.fmt_pretty());
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431
432    #[test]
433    fn test_diagnostic_with_source_chain() {
434        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
435        let diag =
436            Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
437
438        assert_eq!(diag.source_chain.len(), 1);
439        assert!(diag.source_chain[0].contains("File not found"));
440    }
441
442    #[test]
443    fn test_diagnostic_serialization() {
444        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
445            .with_code("E001".to_string())
446            .with_location(Location {
447                file: "test.typ".to_string(),
448                line: 10,
449                column: 5,
450            });
451
452        let json = serde_json::to_string(&diag).unwrap();
453        assert!(json.contains("Test error"));
454        assert!(json.contains("E001"));
455        assert!(json.contains("\"severity\":\"error\""));
456        assert!(json.contains("\"column\":5"));
457    }
458
459    #[test]
460    fn test_render_error_diagnostics_extraction() {
461        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
462        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
463
464        let err = RenderError::CompilationFailed {
465            diags: vec![diag1, diag2],
466        };
467
468        let diags = err.diagnostics();
469        assert_eq!(diags.len(), 2);
470    }
471
472    #[test]
473    fn test_render_error_uniform_single_diagnostic_shape() {
474        // Single-diagnostic kinds carry a one-element vector and expose it
475        // through the same accessors as the multi-diagnostic kinds.
476        let err = RenderError::UnsupportedBackend {
477            diags: vec![Diagnostic::new(
478                Severity::Error,
479                "no such backend".to_string(),
480            )],
481        };
482        assert_eq!(err.diagnostics().len(), 1);
483        assert_eq!(err.to_string(), "no such backend");
484
485        let owned = err.into_diagnostics();
486        assert_eq!(owned.len(), 1);
487        assert_eq!(owned[0].message, "no such backend");
488    }
489
490    #[test]
491    fn test_render_error_display_aggregates_multi_diagnostic() {
492        let err = RenderError::ValidationFailed {
493            diags: vec![
494                Diagnostic::new(Severity::Error, "a".to_string()),
495                Diagnostic::new(Severity::Error, "b".to_string()),
496            ],
497        };
498        assert_eq!(err.to_string(), "Validation failed with 2 error(s)");
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_with_path() {
522        let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
523            .with_code("validation::missing_required".to_string())
524            .with_path("cards.indorsement[0].signature_block".to_string());
525
526        assert_eq!(
527            diag.path.as_deref(),
528            Some("cards.indorsement[0].signature_block")
529        );
530
531        let json = serde_json::to_string(&diag).unwrap();
532        assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
533
534        let pretty = diag.fmt_pretty();
535        assert!(pretty.contains("at cards.indorsement[0].signature_block"));
536    }
537
538    #[test]
539    fn test_diagnostic_path_omitted_when_none() {
540        let diag = Diagnostic::new(Severity::Error, "No path".to_string());
541        let json = serde_json::to_string(&diag).unwrap();
542        assert!(!json.contains("\"path\""));
543    }
544
545    #[test]
546    fn test_diagnostic_fmt_pretty_with_source() {
547        let root_err = std::io::Error::other("Underlying error");
548        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
549            .with_code("E002".to_string())
550            .with_source(&root_err);
551
552        let output = diag.fmt_pretty_with_source();
553        assert!(output.contains("[ERROR]"));
554        assert!(output.contains("Top-level error"));
555        assert!(output.contains("cause 1:"));
556        assert!(output.contains("Underlying error"));
557    }
558
559    #[test]
560    fn test_render_result_with_warnings() {
561        let artifacts = vec![];
562        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
563
564        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
565
566        assert_eq!(result.warnings.len(), 1);
567        assert_eq!(result.warnings[0].message, "Test warning");
568    }
569}