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//! - [`crate::TemplateError`]: Template-specific errors
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::TemplateFailed`]: Template rendering error
26//! - [`RenderError::CompilationFailed`]: Backend compilation errors
27//! - [`RenderError::FormatNotSupported`]: Requested format not supported
28//! - [`RenderError::UnsupportedBackend`]: Backend not registered
29//! - [`RenderError::DynamicAssetCollision`]: Asset filename collision
30//! - [`RenderError::DynamicFontCollision`]: Font filename collision
31//! - [`RenderError::InputTooLarge`]: Input size limits exceeded
32//! - [`RenderError::YamlTooLarge`]: YAML size exceeded maximum
33//! - [`RenderError::NestingTooDeep`]: Nesting depth exceeded maximum
34//! - [`RenderError::OutputTooLarge`]: Template output exceeded maximum size
35//!
36//! ## Examples
37//!
38//! ### Error Handling
39//!
40//! ```no_run
41//! use quillmark_core::{RenderError, error::print_errors};
42//! # use quillmark_core::{RenderResult, OutputFormat};
43//! # struct Workflow;
44//! # impl Workflow {
45//! #     fn render(&self, _: &str, _: Option<()>) -> Result<RenderResult, RenderError> {
46//! #         Ok(RenderResult::new(vec![], OutputFormat::Pdf))
47//! #     }
48//! # }
49//! # let workflow = Workflow;
50//! # let markdown = "";
51//!
52//! match workflow.render(markdown, None) {
53//!     Ok(result) => {
54//!         // Process artifacts
55//!         for artifact in result.artifacts {
56//!             std::fs::write(
57//!                 format!("output.{:?}", artifact.output_format),
58//!                 &artifact.bytes
59//!             )?;
60//!         }
61//!     }
62//!     Err(e) => {
63//!         // Print structured diagnostics
64//!         print_errors(&e);
65//!         
66//!         // Match specific error types
67//!         match e {
68//!             RenderError::CompilationFailed { diags } => {
69//!                 eprintln!("Compilation failed with {} errors:", diags.len());
70//!                 for diag in diags {
71//!                     eprintln!("{}", diag.fmt_pretty());
72//!                 }
73//!             }
74//!             RenderError::InvalidFrontmatter { diag } => {
75//!                 eprintln!("Frontmatter error: {}", diag.message);
76//!             }
77//!             _ => eprintln!("Error: {}", e),
78//!         }
79//!     }
80//! }
81//! # Ok::<(), Box<dyn std::error::Error>>(())
82//! ```
83//!
84//! ### Creating Diagnostics
85//!
86//! ```
87//! use quillmark_core::{Diagnostic, Location, Severity};
88//!
89//! let diag = Diagnostic::new(Severity::Error, "Undefined variable".to_string())
90//!     .with_code("E001".to_string())
91//!     .with_location(Location {
92//!         file: "template.typ".to_string(),
93//!         line: 10,
94//!         col: 5,
95//!     })
96//!     .with_hint("Check variable spelling".to_string());
97//!
98//! println!("{}", diag.fmt_pretty());
99//! ```
100//!
101//! Example output:
102//! ```text
103//! [ERROR] Undefined variable (E001) at template.typ:10:5
104//!   hint: Check variable spelling
105//! ```
106//!
107//! ### Result with Warnings
108//!
109//! ```no_run
110//! # use quillmark_core::{RenderResult, Diagnostic, Severity, OutputFormat};
111//! # let artifacts = vec![];
112//! let result = RenderResult::new(artifacts, OutputFormat::Pdf)
113//!     .with_warning(Diagnostic::new(
114//!         Severity::Warning,
115//!         "Deprecated field used".to_string(),
116//!     ));
117//! ```
118//!
119//! ## Pretty Printing
120//!
121//! The [`Diagnostic`] type provides [`Diagnostic::fmt_pretty()`] for human-readable output with error code, location, and hints.
122//!
123//! ## Machine-Readable Output
124//!
125//! All diagnostic types implement `serde::Serialize` for JSON export:
126//!
127//! ```no_run
128//! # use quillmark_core::{Diagnostic, Severity};
129//! # let diagnostic = Diagnostic::new(Severity::Error, "Test".to_string());
130//! let json = serde_json::to_string(&diagnostic).unwrap();
131//! ```
132
133use crate::OutputFormat;
134
135/// Maximum input size for markdown (10 MB)
136pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
137
138/// Maximum YAML size (1 MB)
139pub const MAX_YAML_SIZE: usize = 1 * 1024 * 1024;
140
141/// Maximum nesting depth for markdown structures (100 levels)
142pub const MAX_NESTING_DEPTH: usize = 100;
143
144/// Maximum template output size (50 MB)
145pub const MAX_TEMPLATE_OUTPUT: usize = 50 * 1024 * 1024;
146
147/// Error severity levels
148#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149pub enum Severity {
150    /// Fatal error that prevents completion
151    Error,
152    /// Non-fatal issue that may need attention
153    Warning,
154    /// Informational message
155    Note,
156}
157
158/// Location information for diagnostics
159#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
160pub struct Location {
161    /// Source file name (e.g., "glue.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 col: u32,
167}
168
169/// Structured diagnostic information
170#[derive(Debug, serde::Serialize)]
171pub struct Diagnostic {
172    /// Error severity level
173    pub severity: Severity,
174    /// Optional error code (e.g., "E001", "typst::syntax")
175    pub code: Option<String>,
176    /// Human-readable error message
177    pub message: String,
178    /// Primary source location
179    pub primary: Option<Location>,
180    /// Optional hint for fixing the error
181    pub hint: Option<String>,
182    /// Source error that caused this diagnostic (for error chaining)
183    /// Note: This field is excluded from serialization as Error trait
184    /// objects cannot be serialized
185    #[serde(skip)]
186    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
187}
188
189impl Diagnostic {
190    /// Create a new diagnostic
191    pub fn new(severity: Severity, message: String) -> Self {
192        Self {
193            severity,
194            code: None,
195            message,
196            primary: None,
197            hint: None,
198            source: None,
199        }
200    }
201
202    /// Set the error code
203    pub fn with_code(mut self, code: String) -> Self {
204        self.code = Some(code);
205        self
206    }
207
208    /// Set the primary location
209    pub fn with_location(mut self, location: Location) -> Self {
210        self.primary = Some(location);
211        self
212    }
213
214    /// Set a hint
215    pub fn with_hint(mut self, hint: String) -> Self {
216        self.hint = Some(hint);
217        self
218    }
219
220    /// Set error source (chainable)
221    pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
222        self.source = Some(source);
223        self
224    }
225
226    /// Get the source chain as a list of error messages
227    pub fn source_chain(&self) -> Vec<String> {
228        let mut chain = Vec::new();
229        let mut current_source = self
230            .source
231            .as_ref()
232            .map(|b| b.as_ref() as &dyn std::error::Error);
233        while let Some(err) = current_source {
234            chain.push(err.to_string());
235            current_source = err.source();
236        }
237        chain
238    }
239
240    /// Format diagnostic for pretty printing
241    pub fn fmt_pretty(&self) -> String {
242        let mut result = format!(
243            "[{}] {}",
244            match self.severity {
245                Severity::Error => "ERROR",
246                Severity::Warning => "WARN",
247                Severity::Note => "NOTE",
248            },
249            self.message
250        );
251
252        if let Some(ref code) = self.code {
253            result.push_str(&format!(" ({})", code));
254        }
255
256        if let Some(ref loc) = self.primary {
257            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.col));
258        }
259
260        if let Some(ref hint) = self.hint {
261            result.push_str(&format!("\n  hint: {}", hint));
262        }
263
264        result
265    }
266
267    /// Format diagnostic with source chain for debugging
268    pub fn fmt_pretty_with_source(&self) -> String {
269        let mut result = self.fmt_pretty();
270
271        for (i, cause) in self.source_chain().iter().enumerate() {
272            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
273        }
274
275        result
276    }
277}
278
279impl std::fmt::Display for Diagnostic {
280    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
281        write!(f, "{}", self.message)
282    }
283}
284
285/// Serializable diagnostic for cross-language boundaries
286///
287/// This type is used when diagnostics need to be serialized and sent across
288/// FFI boundaries (e.g., Python, WASM). Unlike `Diagnostic`, it does not
289/// contain the non-serializable `source` field, but instead includes a
290/// flattened `source_chain` for display purposes.
291#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
292pub struct SerializableDiagnostic {
293    /// Error severity level
294    pub severity: Severity,
295    /// Optional error code (e.g., "E001", "typst::syntax")
296    pub code: Option<String>,
297    /// Human-readable error message
298    pub message: String,
299    /// Primary source location
300    pub primary: Option<Location>,
301    /// Optional hint for fixing the error
302    pub hint: Option<String>,
303    /// Source chain as list of strings (for display purposes)
304    pub source_chain: Vec<String>,
305}
306
307impl From<Diagnostic> for SerializableDiagnostic {
308    fn from(diag: Diagnostic) -> Self {
309        let source_chain = diag.source_chain();
310        Self {
311            severity: diag.severity,
312            code: diag.code,
313            message: diag.message,
314            primary: diag.primary,
315            hint: diag.hint,
316            source_chain,
317        }
318    }
319}
320
321impl From<&Diagnostic> for SerializableDiagnostic {
322    fn from(diag: &Diagnostic) -> Self {
323        Self {
324            severity: diag.severity,
325            code: diag.code.clone(),
326            message: diag.message.clone(),
327            primary: diag.primary.clone(),
328            hint: diag.hint.clone(),
329            source_chain: diag.source_chain(),
330        }
331    }
332}
333
334/// Error type for parsing operations
335#[derive(thiserror::Error, Debug)]
336pub enum ParseError {
337    /// Input too large
338    #[error("Input too large: {size} bytes (max: {max} bytes)")]
339    InputTooLarge {
340        /// Actual size
341        size: usize,
342        /// Maximum allowed size
343        max: usize,
344    },
345
346    /// YAML parsing error
347    #[error("YAML parsing error: {0}")]
348    YamlError(#[from] serde_yaml::Error),
349
350    /// Invalid YAML structure
351    #[error("Invalid YAML structure: {0}")]
352    InvalidStructure(String),
353
354    /// Other parsing errors
355    #[error("{0}")]
356    Other(String),
357}
358
359impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
360    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
361        ParseError::Other(err.to_string())
362    }
363}
364
365impl From<String> for ParseError {
366    fn from(msg: String) -> Self {
367        ParseError::Other(msg)
368    }
369}
370
371/// Main error type for rendering operations
372#[derive(thiserror::Error, Debug)]
373pub enum RenderError {
374    /// Failed to create rendering engine
375    #[error("{diag}")]
376    EngineCreation {
377        /// Diagnostic information
378        diag: Diagnostic,
379    },
380
381    /// Invalid YAML frontmatter in markdown document
382    #[error("{diag}")]
383    InvalidFrontmatter {
384        /// Diagnostic information
385        diag: Diagnostic,
386    },
387
388    /// Template rendering failed
389    #[error("{diag}")]
390    TemplateFailed {
391        /// Diagnostic information
392        diag: Diagnostic,
393    },
394
395    /// Backend compilation failed with one or more errors
396    #[error("Backend compilation failed with {} error(s)", diags.len())]
397    CompilationFailed {
398        /// List of diagnostics
399        diags: Vec<Diagnostic>,
400    },
401
402    /// Requested output format not supported by backend
403    #[error("{diag}")]
404    FormatNotSupported {
405        /// Diagnostic information
406        diag: Diagnostic,
407    },
408
409    /// Backend not registered with engine
410    #[error("{diag}")]
411    UnsupportedBackend {
412        /// Diagnostic information
413        diag: Diagnostic,
414    },
415
416    /// Dynamic asset filename collision
417    #[error("{diag}")]
418    DynamicAssetCollision {
419        /// Diagnostic information
420        diag: Diagnostic,
421    },
422
423    /// Dynamic font filename collision
424    #[error("{diag}")]
425    DynamicFontCollision {
426        /// Diagnostic information
427        diag: Diagnostic,
428    },
429
430    /// Input size limits exceeded
431    #[error("{diag}")]
432    InputTooLarge {
433        /// Diagnostic information
434        diag: Diagnostic,
435    },
436
437    /// YAML size exceeded maximum allowed
438    #[error("{diag}")]
439    YamlTooLarge {
440        /// Diagnostic information
441        diag: Diagnostic,
442    },
443
444    /// Nesting depth exceeded maximum allowed
445    #[error("{diag}")]
446    NestingTooDeep {
447        /// Diagnostic information
448        diag: Diagnostic,
449    },
450
451    /// Template output exceeded maximum size
452    #[error("{diag}")]
453    OutputTooLarge {
454        /// Diagnostic information
455        diag: Diagnostic,
456    },
457
458    /// Validation failed for parsed document
459    #[error("{diag}")]
460    ValidationFailed {
461        /// Diagnostic information
462        diag: Diagnostic,
463    },
464
465    /// Invalid schema definition
466    #[error("{diag}")]
467    InvalidSchema {
468        /// Diagnostic information
469        diag: Diagnostic,
470    },
471
472    /// Quill configuration error
473    #[error("{diag}")]
474    QuillConfig {
475        /// Diagnostic information
476        diag: Diagnostic,
477    },
478}
479
480impl RenderError {
481    /// Extract all diagnostics from this error
482    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
483        match self {
484            RenderError::CompilationFailed { diags } => diags.iter().collect(),
485            RenderError::EngineCreation { diag }
486            | RenderError::InvalidFrontmatter { diag }
487            | RenderError::TemplateFailed { diag }
488            | RenderError::FormatNotSupported { diag }
489            | RenderError::UnsupportedBackend { diag }
490            | RenderError::DynamicAssetCollision { diag }
491            | RenderError::DynamicFontCollision { diag }
492            | RenderError::InputTooLarge { diag }
493            | RenderError::YamlTooLarge { diag }
494            | RenderError::NestingTooDeep { diag }
495            | RenderError::OutputTooLarge { diag }
496            | RenderError::ValidationFailed { diag }
497            | RenderError::InvalidSchema { diag }
498            | RenderError::QuillConfig { diag } => vec![diag],
499        }
500    }
501}
502
503/// Result type containing artifacts and warnings
504#[derive(Debug)]
505pub struct RenderResult {
506    /// Generated output artifacts
507    pub artifacts: Vec<crate::Artifact>,
508    /// Non-fatal diagnostic messages
509    pub warnings: Vec<Diagnostic>,
510    /// Output format that was produced
511    pub output_format: OutputFormat,
512}
513
514impl RenderResult {
515    /// Create a new result with artifacts and output format
516    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
517        Self {
518            artifacts,
519            warnings: Vec::new(),
520            output_format,
521        }
522    }
523
524    /// Add a warning to the result
525    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
526        self.warnings.push(warning);
527        self
528    }
529}
530
531/// Convert minijinja errors to RenderError
532impl From<minijinja::Error> for RenderError {
533    fn from(e: minijinja::Error) -> Self {
534        // Extract location with proper range information
535        let loc = e.line().map(|line| Location {
536            file: e.name().unwrap_or("template").to_string(),
537            line: line as u32,
538            // MiniJinja provides range, extract approximate column
539            col: e.range().map(|r| r.start as u32).unwrap_or(0),
540        });
541
542        // Generate helpful hints based on error kind
543        let hint = generate_minijinja_hint(&e);
544
545        // Create diagnostic with source preservation
546        let mut diag = Diagnostic::new(Severity::Error, e.to_string())
547            .with_code(format!("minijinja::{:?}", e.kind()));
548
549        if let Some(loc) = loc {
550            diag = diag.with_location(loc);
551        }
552
553        if let Some(hint) = hint {
554            diag = diag.with_hint(hint);
555        }
556
557        // Preserve the original error as source
558        diag = diag.with_source(Box::new(e));
559
560        RenderError::TemplateFailed { diag }
561    }
562}
563
564/// Generate helpful hints for common MiniJinja errors
565fn generate_minijinja_hint(e: &minijinja::Error) -> Option<String> {
566    use minijinja::ErrorKind;
567
568    match e.kind() {
569        ErrorKind::UndefinedError => {
570            Some("Check variable spelling and ensure it's defined in frontmatter".to_string())
571        }
572        ErrorKind::InvalidOperation => {
573            Some("Check that you're using the correct filter or operator for this type".to_string())
574        }
575        ErrorKind::SyntaxError => Some(
576            "Check template syntax - look for unclosed tags or invalid expressions".to_string(),
577        ),
578        _ => e.detail().map(|d| d.to_string()),
579    }
580}
581
582/// Helper to print structured errors
583pub fn print_errors(err: &RenderError) {
584    match err {
585        RenderError::CompilationFailed { diags } => {
586            for d in diags {
587                eprintln!("{}", d.fmt_pretty());
588            }
589        }
590        RenderError::TemplateFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
591        RenderError::InvalidFrontmatter { diag } => eprintln!("{}", diag.fmt_pretty()),
592        RenderError::EngineCreation { diag } => eprintln!("{}", diag.fmt_pretty()),
593        RenderError::FormatNotSupported { diag } => eprintln!("{}", diag.fmt_pretty()),
594        RenderError::UnsupportedBackend { diag } => eprintln!("{}", diag.fmt_pretty()),
595        RenderError::DynamicAssetCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
596        RenderError::DynamicFontCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
597        RenderError::InputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
598        RenderError::YamlTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
599        RenderError::NestingTooDeep { diag } => eprintln!("{}", diag.fmt_pretty()),
600        RenderError::OutputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
601        RenderError::ValidationFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
602        RenderError::InvalidSchema { diag } => eprintln!("{}", diag.fmt_pretty()),
603        RenderError::QuillConfig { diag } => eprintln!("{}", diag.fmt_pretty()),
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610
611    #[test]
612    fn test_diagnostic_with_source_chain() {
613        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
614        let diag = Diagnostic::new(Severity::Error, "Rendering failed".to_string())
615            .with_source(Box::new(root_err));
616
617        let chain = diag.source_chain();
618        assert_eq!(chain.len(), 1);
619        assert!(chain[0].contains("File not found"));
620    }
621
622    #[test]
623    fn test_diagnostic_serialization() {
624        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
625            .with_code("E001".to_string())
626            .with_location(Location {
627                file: "test.typ".to_string(),
628                line: 10,
629                col: 5,
630            });
631
632        let serializable: SerializableDiagnostic = diag.into();
633        let json = serde_json::to_string(&serializable).unwrap();
634        assert!(json.contains("Test error"));
635        assert!(json.contains("E001"));
636    }
637
638    #[test]
639    fn test_render_error_diagnostics_extraction() {
640        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
641        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
642
643        let err = RenderError::CompilationFailed {
644            diags: vec![diag1, diag2],
645        };
646
647        let diags = err.diagnostics();
648        assert_eq!(diags.len(), 2);
649    }
650
651    #[test]
652    fn test_diagnostic_fmt_pretty() {
653        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
654            .with_code("W001".to_string())
655            .with_location(Location {
656                file: "input.md".to_string(),
657                line: 5,
658                col: 10,
659            })
660            .with_hint("Use the new field name instead".to_string());
661
662        let output = diag.fmt_pretty();
663        assert!(output.contains("[WARN]"));
664        assert!(output.contains("Deprecated field used"));
665        assert!(output.contains("W001"));
666        assert!(output.contains("input.md:5:10"));
667        assert!(output.contains("hint:"));
668    }
669
670    #[test]
671    fn test_diagnostic_fmt_pretty_with_source() {
672        let root_err = std::io::Error::new(std::io::ErrorKind::Other, "Underlying error");
673        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
674            .with_code("E002".to_string())
675            .with_source(Box::new(root_err));
676
677        let output = diag.fmt_pretty_with_source();
678        assert!(output.contains("[ERROR]"));
679        assert!(output.contains("Top-level error"));
680        assert!(output.contains("cause 1:"));
681        assert!(output.contains("Underlying error"));
682    }
683
684    #[test]
685    fn test_render_result_with_warnings() {
686        let artifacts = vec![];
687        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
688
689        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
690
691        assert_eq!(result.warnings.len(), 1);
692        assert_eq!(result.warnings[0].message, "Test warning");
693    }
694
695    #[test]
696    fn test_minijinja_error_conversion() {
697        // Use undefined variable with strict mode to trigger an error
698        let template_str = "{{ undefined_var }}";
699        let mut env = minijinja::Environment::new();
700        env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
701
702        let result = env.render_str(template_str, minijinja::context! {});
703        assert!(
704            result.is_err(),
705            "Expected rendering to fail with undefined variable"
706        );
707
708        let minijinja_err = result.unwrap_err();
709        let render_err: RenderError = minijinja_err.into();
710
711        match render_err {
712            RenderError::TemplateFailed { diag } => {
713                assert_eq!(diag.severity, Severity::Error);
714                assert!(diag.code.is_some());
715                assert!(diag.hint.is_some());
716                assert!(diag.source.is_some());
717            }
718            _ => panic!("Expected TemplateFailed error"),
719        }
720    }
721}