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 = 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/// Maximum YAML nesting depth (100 levels)
148/// Prevents stack overflow from deeply nested YAML structures
149pub const MAX_YAML_DEPTH: usize = 100;
150
151/// Maximum number of CARD blocks allowed per document
152/// Prevents memory exhaustion from documents with excessive card blocks
153pub const MAX_CARD_COUNT: usize = 1000;
154
155/// Maximum number of fields allowed per document
156/// Prevents memory exhaustion from documents with excessive fields
157pub const MAX_FIELD_COUNT: usize = 1000;
158
159/// Error severity levels
160#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub enum Severity {
162    /// Fatal error that prevents completion
163    Error,
164    /// Non-fatal issue that may need attention
165    Warning,
166    /// Informational message
167    Note,
168}
169
170/// Location information for diagnostics
171#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
172pub struct Location {
173    /// Source file name (e.g., "plate.typ", "template.typ", "input.md")
174    pub file: String,
175    /// Line number (1-indexed)
176    pub line: u32,
177    /// Column number (1-indexed)
178    pub col: u32,
179}
180
181/// Structured diagnostic information
182#[derive(Debug, serde::Serialize)]
183pub struct Diagnostic {
184    /// Error severity level
185    pub severity: Severity,
186    /// Optional error code (e.g., "E001", "typst::syntax")
187    pub code: Option<String>,
188    /// Human-readable error message
189    pub message: String,
190    /// Primary source location
191    pub primary: Option<Location>,
192    /// Optional hint for fixing the error
193    pub hint: Option<String>,
194    /// Source error that caused this diagnostic (for error chaining)
195    /// Note: This field is excluded from serialization as Error trait
196    /// objects cannot be serialized
197    #[serde(skip)]
198    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
199}
200
201impl Diagnostic {
202    /// Create a new diagnostic
203    pub fn new(severity: Severity, message: String) -> Self {
204        Self {
205            severity,
206            code: None,
207            message,
208            primary: None,
209            hint: None,
210            source: None,
211        }
212    }
213
214    /// Set the error code
215    pub fn with_code(mut self, code: String) -> Self {
216        self.code = Some(code);
217        self
218    }
219
220    /// Set the primary location
221    pub fn with_location(mut self, location: Location) -> Self {
222        self.primary = Some(location);
223        self
224    }
225
226    /// Set a hint
227    pub fn with_hint(mut self, hint: String) -> Self {
228        self.hint = Some(hint);
229        self
230    }
231
232    /// Set error source (chainable)
233    pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
234        self.source = Some(source);
235        self
236    }
237
238    /// Get the source chain as a list of error messages
239    pub fn source_chain(&self) -> Vec<String> {
240        let mut chain = Vec::new();
241        let mut current_source = self
242            .source
243            .as_ref()
244            .map(|b| b.as_ref() as &dyn std::error::Error);
245        while let Some(err) = current_source {
246            chain.push(err.to_string());
247            current_source = err.source();
248        }
249        chain
250    }
251
252    /// Format diagnostic for pretty printing
253    pub fn fmt_pretty(&self) -> String {
254        let mut result = format!(
255            "[{}] {}",
256            match self.severity {
257                Severity::Error => "ERROR",
258                Severity::Warning => "WARN",
259                Severity::Note => "NOTE",
260            },
261            self.message
262        );
263
264        if let Some(ref code) = self.code {
265            result.push_str(&format!(" ({})", code));
266        }
267
268        if let Some(ref loc) = self.primary {
269            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.col));
270        }
271
272        if let Some(ref hint) = self.hint {
273            result.push_str(&format!("\n  hint: {}", hint));
274        }
275
276        result
277    }
278
279    /// Format diagnostic with source chain for debugging
280    pub fn fmt_pretty_with_source(&self) -> String {
281        let mut result = self.fmt_pretty();
282
283        for (i, cause) in self.source_chain().iter().enumerate() {
284            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
285        }
286
287        result
288    }
289}
290
291impl std::fmt::Display for Diagnostic {
292    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293        write!(f, "{}", self.message)
294    }
295}
296
297/// Serializable diagnostic for cross-language boundaries
298///
299/// This type is used when diagnostics need to be serialized and sent across
300/// FFI boundaries (e.g., Python, WASM). Unlike `Diagnostic`, it does not
301/// contain the non-serializable `source` field, but instead includes a
302/// flattened `source_chain` for display purposes.
303#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
304pub struct SerializableDiagnostic {
305    /// Error severity level
306    pub severity: Severity,
307    /// Optional error code (e.g., "E001", "typst::syntax")
308    pub code: Option<String>,
309    /// Human-readable error message
310    pub message: String,
311    /// Primary source location
312    pub primary: Option<Location>,
313    /// Optional hint for fixing the error
314    pub hint: Option<String>,
315    /// Source chain as list of strings (for display purposes)
316    pub source_chain: Vec<String>,
317}
318
319impl From<Diagnostic> for SerializableDiagnostic {
320    fn from(diag: Diagnostic) -> Self {
321        let source_chain = diag.source_chain();
322        Self {
323            severity: diag.severity,
324            code: diag.code,
325            message: diag.message,
326            primary: diag.primary,
327            hint: diag.hint,
328            source_chain,
329        }
330    }
331}
332
333impl From<&Diagnostic> for SerializableDiagnostic {
334    fn from(diag: &Diagnostic) -> Self {
335        Self {
336            severity: diag.severity,
337            code: diag.code.clone(),
338            message: diag.message.clone(),
339            primary: diag.primary.clone(),
340            hint: diag.hint.clone(),
341            source_chain: diag.source_chain(),
342        }
343    }
344}
345
346/// Error type for parsing operations
347#[derive(thiserror::Error, Debug)]
348pub enum ParseError {
349    /// Input too large
350    #[error("Input too large: {size} bytes (max: {max} bytes)")]
351    InputTooLarge {
352        /// Actual size
353        size: usize,
354        /// Maximum allowed size
355        max: usize,
356    },
357
358    /// YAML parsing error
359    #[error("YAML parsing error: {0}")]
360    YamlError(#[from] serde_saphyr::Error),
361
362    /// JSON parsing/conversion error
363    #[error("JSON error: {0}")]
364    JsonError(#[from] serde_json::Error),
365
366    /// Invalid YAML structure
367    #[error("Invalid YAML structure: {0}")]
368    InvalidStructure(String),
369
370    /// Missing CARD directive in inline metadata block
371    #[error("{}", .diag.message)]
372    MissingCardDirective {
373        /// Diagnostic information with hint
374        diag: Box<Diagnostic>,
375    },
376
377    /// YAML parsing error with location context
378    #[error("YAML error at line {line}: {message}")]
379    YamlErrorWithLocation {
380        /// Error message
381        message: String,
382        /// Line number in the source document (1-indexed)
383        line: usize,
384        /// Index of the metadata block (0-indexed)
385        block_index: usize,
386    },
387
388    /// Other parsing errors
389    #[error("{0}")]
390    Other(String),
391}
392
393impl ParseError {
394    /// Create a MissingCardDirective error with helpful hint
395    pub fn missing_card_directive() -> Self {
396        let diag = Diagnostic::new(
397            Severity::Error,
398            "Inline metadata block missing CARD directive".to_string(),
399        )
400        .with_code("parse::missing_card".to_string())
401        .with_hint(
402            "Add 'CARD: <card_type>' to specify which card this block belongs to. \
403            Example:\n---\nCARD: my_card_type\nfield: value\n---"
404                .to_string(),
405        );
406        ParseError::MissingCardDirective {
407            diag: Box::new(diag),
408        }
409    }
410
411    /// Convert the parse error into a structured diagnostic
412    pub fn to_diagnostic(&self) -> Diagnostic {
413        match self {
414            ParseError::MissingCardDirective { diag } => Diagnostic {
415                severity: diag.severity,
416                code: diag.code.clone(),
417                message: diag.message.clone(),
418                primary: diag.primary.clone(),
419                hint: diag.hint.clone(),
420                source: None, // Cannot clone trait object, but it's empty in this case usually
421            },
422            ParseError::InputTooLarge { size, max } => Diagnostic::new(
423                Severity::Error,
424                format!("Input too large: {} bytes (max: {} bytes)", size, max),
425            )
426            .with_code("parse::input_too_large".to_string()),
427            ParseError::YamlError(e) => {
428                Diagnostic::new(Severity::Error, format!("YAML parsing error: {}", e))
429                    .with_code("parse::yaml_error".to_string())
430            } // serde_saphyr::Error implements Error+Clone? No, usually Error is not Clone.
431            ParseError::JsonError(e) => {
432                Diagnostic::new(Severity::Error, format!("JSON conversion error: {}", e))
433                    .with_code("parse::json_error".to_string())
434            }
435            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
436                .with_code("parse::invalid_structure".to_string()),
437            ParseError::YamlErrorWithLocation {
438                message,
439                line,
440                block_index,
441            } => Diagnostic::new(
442                Severity::Error,
443                format!(
444                    "YAML error at line {} (block {}): {}",
445                    line, block_index, message
446                ),
447            )
448            .with_code("parse::yaml_error_with_location".to_string()),
449            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
450        }
451    }
452}
453
454impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
455    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
456        ParseError::Other(err.to_string())
457    }
458}
459
460impl From<String> for ParseError {
461    fn from(msg: String) -> Self {
462        ParseError::Other(msg)
463    }
464}
465
466impl From<&str> for ParseError {
467    fn from(msg: &str) -> Self {
468        ParseError::Other(msg.to_string())
469    }
470}
471
472/// Main error type for rendering operations
473#[derive(thiserror::Error, Debug)]
474pub enum RenderError {
475    /// Failed to create rendering engine
476    #[error("{diag}")]
477    EngineCreation {
478        /// Diagnostic information
479        diag: Box<Diagnostic>,
480    },
481
482    /// Invalid YAML frontmatter in markdown document
483    #[error("{diag}")]
484    InvalidFrontmatter {
485        /// Diagnostic information
486        diag: Box<Diagnostic>,
487    },
488
489    /// Template rendering failed
490    #[error("{diag}")]
491    TemplateFailed {
492        /// Diagnostic information
493        diag: Box<Diagnostic>,
494    },
495
496    /// Backend compilation failed with one or more errors
497    #[error("Backend compilation failed with {} error(s)", diags.len())]
498    CompilationFailed {
499        /// List of diagnostics
500        diags: Vec<Diagnostic>,
501    },
502
503    /// Requested output format not supported by backend
504    #[error("{diag}")]
505    FormatNotSupported {
506        /// Diagnostic information
507        diag: Box<Diagnostic>,
508    },
509
510    /// Backend not registered with engine
511    #[error("{diag}")]
512    UnsupportedBackend {
513        /// Diagnostic information
514        diag: Box<Diagnostic>,
515    },
516
517    /// Dynamic asset filename collision
518    #[error("{diag}")]
519    DynamicAssetCollision {
520        /// Diagnostic information
521        diag: Box<Diagnostic>,
522    },
523
524    /// Dynamic font filename collision
525    #[error("{diag}")]
526    DynamicFontCollision {
527        /// Diagnostic information
528        diag: Box<Diagnostic>,
529    },
530
531    /// Input size limits exceeded
532    #[error("{diag}")]
533    InputTooLarge {
534        /// Diagnostic information
535        diag: Box<Diagnostic>,
536    },
537
538    /// YAML size exceeded maximum allowed
539    #[error("{diag}")]
540    YamlTooLarge {
541        /// Diagnostic information
542        diag: Box<Diagnostic>,
543    },
544
545    /// Nesting depth exceeded maximum allowed
546    #[error("{diag}")]
547    NestingTooDeep {
548        /// Diagnostic information
549        diag: Box<Diagnostic>,
550    },
551
552    /// Template output exceeded maximum size
553    #[error("{diag}")]
554    OutputTooLarge {
555        /// Diagnostic information
556        diag: Box<Diagnostic>,
557    },
558
559    /// Validation failed for parsed document
560    #[error("{diag}")]
561    ValidationFailed {
562        /// Diagnostic information
563        diag: Box<Diagnostic>,
564    },
565
566    /// Invalid schema definition
567    #[error("{diag}")]
568    InvalidSchema {
569        /// Diagnostic information
570        diag: Box<Diagnostic>,
571    },
572
573    /// Quill configuration error
574    #[error("{diag}")]
575    QuillConfig {
576        /// Diagnostic information
577        diag: Box<Diagnostic>,
578    },
579}
580
581impl RenderError {
582    /// Extract all diagnostics from this error
583    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
584        match self {
585            RenderError::CompilationFailed { diags } => diags.iter().collect(),
586            RenderError::EngineCreation { diag }
587            | RenderError::InvalidFrontmatter { diag }
588            | RenderError::TemplateFailed { diag }
589            | RenderError::FormatNotSupported { diag }
590            | RenderError::UnsupportedBackend { diag }
591            | RenderError::DynamicAssetCollision { diag }
592            | RenderError::DynamicFontCollision { diag }
593            | RenderError::InputTooLarge { diag }
594            | RenderError::YamlTooLarge { diag }
595            | RenderError::NestingTooDeep { diag }
596            | RenderError::OutputTooLarge { diag }
597            | RenderError::ValidationFailed { diag }
598            | RenderError::InvalidSchema { diag }
599            | RenderError::QuillConfig { diag } => vec![diag.as_ref()],
600        }
601    }
602}
603
604/// Result type containing artifacts and warnings
605#[derive(Debug)]
606pub struct RenderResult {
607    /// Generated output artifacts
608    pub artifacts: Vec<crate::Artifact>,
609    /// Non-fatal diagnostic messages
610    pub warnings: Vec<Diagnostic>,
611    /// Output format that was produced
612    pub output_format: OutputFormat,
613}
614
615impl RenderResult {
616    /// Create a new result with artifacts and output format
617    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
618        Self {
619            artifacts,
620            warnings: Vec::new(),
621            output_format,
622        }
623    }
624
625    /// Add a warning to the result
626    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
627        self.warnings.push(warning);
628        self
629    }
630}
631
632/// Convert minijinja errors to RenderError
633impl From<minijinja::Error> for RenderError {
634    fn from(e: minijinja::Error) -> Self {
635        // Extract location with proper range information
636        let loc = e.line().map(|line| Location {
637            file: e.name().unwrap_or("template").to_string(),
638            line: line as u32,
639            // MiniJinja provides range, extract approximate column
640            col: e.range().map(|r| r.start as u32).unwrap_or(0),
641        });
642
643        // Generate helpful hints based on error kind
644        let hint = generate_minijinja_hint(&e);
645
646        // Create diagnostic with source preservation
647        let mut diag = Diagnostic::new(Severity::Error, e.to_string())
648            .with_code(format!("minijinja::{:?}", e.kind()));
649
650        if let Some(loc) = loc {
651            diag = diag.with_location(loc);
652        }
653
654        if let Some(hint) = hint {
655            diag = diag.with_hint(hint);
656        }
657
658        // Preserve the original error as source
659        diag = diag.with_source(Box::new(e));
660
661        RenderError::TemplateFailed {
662            diag: Box::new(diag),
663        }
664    }
665}
666
667/// Generate helpful hints for common MiniJinja errors
668fn generate_minijinja_hint(e: &minijinja::Error) -> Option<String> {
669    use minijinja::ErrorKind;
670
671    match e.kind() {
672        ErrorKind::UndefinedError => {
673            Some("Check variable spelling and ensure it's defined in frontmatter".to_string())
674        }
675        ErrorKind::InvalidOperation => {
676            Some("Check that you're using the correct filter or operator for this type".to_string())
677        }
678        ErrorKind::SyntaxError => Some(
679            "Check template syntax - look for unclosed tags or invalid expressions".to_string(),
680        ),
681        _ => e.detail().map(|d| d.to_string()),
682    }
683}
684
685/// Helper to print structured errors
686pub fn print_errors(err: &RenderError) {
687    match err {
688        RenderError::CompilationFailed { diags } => {
689            for d in diags {
690                eprintln!("{}", d.fmt_pretty());
691            }
692        }
693        RenderError::TemplateFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
694        RenderError::InvalidFrontmatter { diag } => eprintln!("{}", diag.fmt_pretty()),
695        RenderError::EngineCreation { diag } => eprintln!("{}", diag.fmt_pretty()),
696        RenderError::FormatNotSupported { diag } => eprintln!("{}", diag.fmt_pretty()),
697        RenderError::UnsupportedBackend { diag } => eprintln!("{}", diag.fmt_pretty()),
698        RenderError::DynamicAssetCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
699        RenderError::DynamicFontCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
700        RenderError::InputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
701        RenderError::YamlTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
702        RenderError::NestingTooDeep { diag } => eprintln!("{}", diag.fmt_pretty()),
703        RenderError::OutputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
704        RenderError::ValidationFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
705        RenderError::InvalidSchema { diag } => eprintln!("{}", diag.fmt_pretty()),
706        RenderError::QuillConfig { diag } => eprintln!("{}", diag.fmt_pretty()),
707    }
708}
709
710#[cfg(test)]
711mod tests {
712    use super::*;
713
714    #[test]
715    fn test_diagnostic_with_source_chain() {
716        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
717        let diag = Diagnostic::new(Severity::Error, "Rendering failed".to_string())
718            .with_source(Box::new(root_err));
719
720        let chain = diag.source_chain();
721        assert_eq!(chain.len(), 1);
722        assert!(chain[0].contains("File not found"));
723    }
724
725    #[test]
726    fn test_diagnostic_serialization() {
727        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
728            .with_code("E001".to_string())
729            .with_location(Location {
730                file: "test.typ".to_string(),
731                line: 10,
732                col: 5,
733            });
734
735        let serializable: SerializableDiagnostic = diag.into();
736        let json = serde_json::to_string(&serializable).unwrap();
737        assert!(json.contains("Test error"));
738        assert!(json.contains("E001"));
739    }
740
741    #[test]
742    fn test_render_error_diagnostics_extraction() {
743        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
744        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
745
746        let err = RenderError::CompilationFailed {
747            diags: vec![diag1, diag2],
748        };
749
750        let diags = err.diagnostics();
751        assert_eq!(diags.len(), 2);
752    }
753
754    #[test]
755    fn test_diagnostic_fmt_pretty() {
756        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
757            .with_code("W001".to_string())
758            .with_location(Location {
759                file: "input.md".to_string(),
760                line: 5,
761                col: 10,
762            })
763            .with_hint("Use the new field name instead".to_string());
764
765        let output = diag.fmt_pretty();
766        assert!(output.contains("[WARN]"));
767        assert!(output.contains("Deprecated field used"));
768        assert!(output.contains("W001"));
769        assert!(output.contains("input.md:5:10"));
770        assert!(output.contains("hint:"));
771    }
772
773    #[test]
774    fn test_diagnostic_fmt_pretty_with_source() {
775        let root_err = std::io::Error::other("Underlying error");
776        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
777            .with_code("E002".to_string())
778            .with_source(Box::new(root_err));
779
780        let output = diag.fmt_pretty_with_source();
781        assert!(output.contains("[ERROR]"));
782        assert!(output.contains("Top-level error"));
783        assert!(output.contains("cause 1:"));
784        assert!(output.contains("Underlying error"));
785    }
786
787    #[test]
788    fn test_render_result_with_warnings() {
789        let artifacts = vec![];
790        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
791
792        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
793
794        assert_eq!(result.warnings.len(), 1);
795        assert_eq!(result.warnings[0].message, "Test warning");
796    }
797
798    #[test]
799    fn test_minijinja_error_conversion() {
800        // Use undefined variable with strict mode to trigger an error
801        let template_str = "{{ undefined_var }}";
802        let mut env = minijinja::Environment::new();
803        env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
804
805        let result = env.render_str(template_str, minijinja::context! {});
806        assert!(
807            result.is_err(),
808            "Expected rendering to fail with undefined variable"
809        );
810
811        let minijinja_err = result.unwrap_err();
812        let render_err: RenderError = minijinja_err.into();
813
814        match render_err {
815            RenderError::TemplateFailed { diag } => {
816                assert_eq!(diag.severity, Severity::Error);
817                assert!(diag.code.is_some());
818                assert!(diag.hint.is_some());
819                assert!(diag.source.is_some());
820            }
821            _ => panic!("Expected TemplateFailed error"),
822        }
823    }
824}