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/// 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., "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 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    /// JSON parsing/conversion error
351    #[error("JSON error: {0}")]
352    JsonError(#[from] serde_json::Error),
353
354    /// Invalid YAML structure
355    #[error("Invalid YAML structure: {0}")]
356    InvalidStructure(String),
357
358    /// Missing CARD directive in inline metadata block
359    #[error("{}", .diag.message)]
360    MissingCardDirective {
361        /// Diagnostic information with hint
362        diag: Diagnostic,
363    },
364
365    /// Other parsing errors
366    #[error("{0}")]
367    Other(String),
368}
369
370impl ParseError {
371    /// Create a MissingCardDirective error with helpful hint
372    pub fn missing_card_directive() -> Self {
373        let diag = Diagnostic::new(
374            Severity::Error,
375            "Inline metadata block missing CARD directive".to_string(),
376        )
377        .with_code("parse::missing_card".to_string())
378        .with_hint(
379            "Add 'CARD: <card_type>' to specify which card this block belongs to. \
380            Example:\n---\nCARD: my_card_type\nfield: value\n---"
381                .to_string(),
382        );
383        ParseError::MissingCardDirective { diag }
384    }
385
386    /// Convert the parse error into a structured diagnostic
387    pub fn to_diagnostic(&self) -> Diagnostic {
388        match self {
389            ParseError::MissingCardDirective { diag } => Diagnostic {
390                severity: diag.severity,
391                code: diag.code.clone(),
392                message: diag.message.clone(),
393                primary: diag.primary.clone(),
394                hint: diag.hint.clone(),
395                source: None, // Cannot clone trait object, but it's empty in this case usually
396            },
397            ParseError::InputTooLarge { size, max } => Diagnostic::new(
398                Severity::Error,
399                format!("Input too large: {} bytes (max: {} bytes)", size, max),
400            )
401            .with_code("parse::input_too_large".to_string()),
402            ParseError::YamlError(e) => {
403                Diagnostic::new(Severity::Error, format!("YAML parsing error: {}", e))
404                    .with_code("parse::yaml_error".to_string())
405            } // serde_yaml::Error implements Error+Clone? No, usually Error is not Clone.
406            ParseError::JsonError(e) => {
407                Diagnostic::new(Severity::Error, format!("JSON conversion error: {}", e))
408                    .with_code("parse::json_error".to_string())
409            }
410            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
411                .with_code("parse::invalid_structure".to_string()),
412            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
413        }
414    }
415}
416
417impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
418    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
419        ParseError::Other(err.to_string())
420    }
421}
422
423impl From<String> for ParseError {
424    fn from(msg: String) -> Self {
425        ParseError::Other(msg)
426    }
427}
428
429impl From<&str> for ParseError {
430    fn from(msg: &str) -> Self {
431        ParseError::Other(msg.to_string())
432    }
433}
434
435/// Main error type for rendering operations
436#[derive(thiserror::Error, Debug)]
437pub enum RenderError {
438    /// Failed to create rendering engine
439    #[error("{diag}")]
440    EngineCreation {
441        /// Diagnostic information
442        diag: Diagnostic,
443    },
444
445    /// Invalid YAML frontmatter in markdown document
446    #[error("{diag}")]
447    InvalidFrontmatter {
448        /// Diagnostic information
449        diag: Diagnostic,
450    },
451
452    /// Template rendering failed
453    #[error("{diag}")]
454    TemplateFailed {
455        /// Diagnostic information
456        diag: Diagnostic,
457    },
458
459    /// Backend compilation failed with one or more errors
460    #[error("Backend compilation failed with {} error(s)", diags.len())]
461    CompilationFailed {
462        /// List of diagnostics
463        diags: Vec<Diagnostic>,
464    },
465
466    /// Requested output format not supported by backend
467    #[error("{diag}")]
468    FormatNotSupported {
469        /// Diagnostic information
470        diag: Diagnostic,
471    },
472
473    /// Backend not registered with engine
474    #[error("{diag}")]
475    UnsupportedBackend {
476        /// Diagnostic information
477        diag: Diagnostic,
478    },
479
480    /// Dynamic asset filename collision
481    #[error("{diag}")]
482    DynamicAssetCollision {
483        /// Diagnostic information
484        diag: Diagnostic,
485    },
486
487    /// Dynamic font filename collision
488    #[error("{diag}")]
489    DynamicFontCollision {
490        /// Diagnostic information
491        diag: Diagnostic,
492    },
493
494    /// Input size limits exceeded
495    #[error("{diag}")]
496    InputTooLarge {
497        /// Diagnostic information
498        diag: Diagnostic,
499    },
500
501    /// YAML size exceeded maximum allowed
502    #[error("{diag}")]
503    YamlTooLarge {
504        /// Diagnostic information
505        diag: Diagnostic,
506    },
507
508    /// Nesting depth exceeded maximum allowed
509    #[error("{diag}")]
510    NestingTooDeep {
511        /// Diagnostic information
512        diag: Diagnostic,
513    },
514
515    /// Template output exceeded maximum size
516    #[error("{diag}")]
517    OutputTooLarge {
518        /// Diagnostic information
519        diag: Diagnostic,
520    },
521
522    /// Validation failed for parsed document
523    #[error("{diag}")]
524    ValidationFailed {
525        /// Diagnostic information
526        diag: Diagnostic,
527    },
528
529    /// Invalid schema definition
530    #[error("{diag}")]
531    InvalidSchema {
532        /// Diagnostic information
533        diag: Diagnostic,
534    },
535
536    /// Quill configuration error
537    #[error("{diag}")]
538    QuillConfig {
539        /// Diagnostic information
540        diag: Diagnostic,
541    },
542}
543
544impl RenderError {
545    /// Extract all diagnostics from this error
546    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
547        match self {
548            RenderError::CompilationFailed { diags } => diags.iter().collect(),
549            RenderError::EngineCreation { diag }
550            | RenderError::InvalidFrontmatter { diag }
551            | RenderError::TemplateFailed { diag }
552            | RenderError::FormatNotSupported { diag }
553            | RenderError::UnsupportedBackend { diag }
554            | RenderError::DynamicAssetCollision { diag }
555            | RenderError::DynamicFontCollision { diag }
556            | RenderError::InputTooLarge { diag }
557            | RenderError::YamlTooLarge { diag }
558            | RenderError::NestingTooDeep { diag }
559            | RenderError::OutputTooLarge { diag }
560            | RenderError::ValidationFailed { diag }
561            | RenderError::InvalidSchema { diag }
562            | RenderError::QuillConfig { diag } => vec![diag],
563        }
564    }
565}
566
567/// Result type containing artifacts and warnings
568#[derive(Debug)]
569pub struct RenderResult {
570    /// Generated output artifacts
571    pub artifacts: Vec<crate::Artifact>,
572    /// Non-fatal diagnostic messages
573    pub warnings: Vec<Diagnostic>,
574    /// Output format that was produced
575    pub output_format: OutputFormat,
576}
577
578impl RenderResult {
579    /// Create a new result with artifacts and output format
580    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
581        Self {
582            artifacts,
583            warnings: Vec::new(),
584            output_format,
585        }
586    }
587
588    /// Add a warning to the result
589    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
590        self.warnings.push(warning);
591        self
592    }
593}
594
595/// Convert minijinja errors to RenderError
596impl From<minijinja::Error> for RenderError {
597    fn from(e: minijinja::Error) -> Self {
598        // Extract location with proper range information
599        let loc = e.line().map(|line| Location {
600            file: e.name().unwrap_or("template").to_string(),
601            line: line as u32,
602            // MiniJinja provides range, extract approximate column
603            col: e.range().map(|r| r.start as u32).unwrap_or(0),
604        });
605
606        // Generate helpful hints based on error kind
607        let hint = generate_minijinja_hint(&e);
608
609        // Create diagnostic with source preservation
610        let mut diag = Diagnostic::new(Severity::Error, e.to_string())
611            .with_code(format!("minijinja::{:?}", e.kind()));
612
613        if let Some(loc) = loc {
614            diag = diag.with_location(loc);
615        }
616
617        if let Some(hint) = hint {
618            diag = diag.with_hint(hint);
619        }
620
621        // Preserve the original error as source
622        diag = diag.with_source(Box::new(e));
623
624        RenderError::TemplateFailed { diag }
625    }
626}
627
628/// Generate helpful hints for common MiniJinja errors
629fn generate_minijinja_hint(e: &minijinja::Error) -> Option<String> {
630    use minijinja::ErrorKind;
631
632    match e.kind() {
633        ErrorKind::UndefinedError => {
634            Some("Check variable spelling and ensure it's defined in frontmatter".to_string())
635        }
636        ErrorKind::InvalidOperation => {
637            Some("Check that you're using the correct filter or operator for this type".to_string())
638        }
639        ErrorKind::SyntaxError => Some(
640            "Check template syntax - look for unclosed tags or invalid expressions".to_string(),
641        ),
642        _ => e.detail().map(|d| d.to_string()),
643    }
644}
645
646/// Helper to print structured errors
647pub fn print_errors(err: &RenderError) {
648    match err {
649        RenderError::CompilationFailed { diags } => {
650            for d in diags {
651                eprintln!("{}", d.fmt_pretty());
652            }
653        }
654        RenderError::TemplateFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
655        RenderError::InvalidFrontmatter { diag } => eprintln!("{}", diag.fmt_pretty()),
656        RenderError::EngineCreation { diag } => eprintln!("{}", diag.fmt_pretty()),
657        RenderError::FormatNotSupported { diag } => eprintln!("{}", diag.fmt_pretty()),
658        RenderError::UnsupportedBackend { diag } => eprintln!("{}", diag.fmt_pretty()),
659        RenderError::DynamicAssetCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
660        RenderError::DynamicFontCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
661        RenderError::InputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
662        RenderError::YamlTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
663        RenderError::NestingTooDeep { diag } => eprintln!("{}", diag.fmt_pretty()),
664        RenderError::OutputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
665        RenderError::ValidationFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
666        RenderError::InvalidSchema { diag } => eprintln!("{}", diag.fmt_pretty()),
667        RenderError::QuillConfig { diag } => eprintln!("{}", diag.fmt_pretty()),
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::*;
674
675    #[test]
676    fn test_diagnostic_with_source_chain() {
677        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
678        let diag = Diagnostic::new(Severity::Error, "Rendering failed".to_string())
679            .with_source(Box::new(root_err));
680
681        let chain = diag.source_chain();
682        assert_eq!(chain.len(), 1);
683        assert!(chain[0].contains("File not found"));
684    }
685
686    #[test]
687    fn test_diagnostic_serialization() {
688        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
689            .with_code("E001".to_string())
690            .with_location(Location {
691                file: "test.typ".to_string(),
692                line: 10,
693                col: 5,
694            });
695
696        let serializable: SerializableDiagnostic = diag.into();
697        let json = serde_json::to_string(&serializable).unwrap();
698        assert!(json.contains("Test error"));
699        assert!(json.contains("E001"));
700    }
701
702    #[test]
703    fn test_render_error_diagnostics_extraction() {
704        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
705        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
706
707        let err = RenderError::CompilationFailed {
708            diags: vec![diag1, diag2],
709        };
710
711        let diags = err.diagnostics();
712        assert_eq!(diags.len(), 2);
713    }
714
715    #[test]
716    fn test_diagnostic_fmt_pretty() {
717        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
718            .with_code("W001".to_string())
719            .with_location(Location {
720                file: "input.md".to_string(),
721                line: 5,
722                col: 10,
723            })
724            .with_hint("Use the new field name instead".to_string());
725
726        let output = diag.fmt_pretty();
727        assert!(output.contains("[WARN]"));
728        assert!(output.contains("Deprecated field used"));
729        assert!(output.contains("W001"));
730        assert!(output.contains("input.md:5:10"));
731        assert!(output.contains("hint:"));
732    }
733
734    #[test]
735    fn test_diagnostic_fmt_pretty_with_source() {
736        let root_err = std::io::Error::new(std::io::ErrorKind::Other, "Underlying error");
737        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
738            .with_code("E002".to_string())
739            .with_source(Box::new(root_err));
740
741        let output = diag.fmt_pretty_with_source();
742        assert!(output.contains("[ERROR]"));
743        assert!(output.contains("Top-level error"));
744        assert!(output.contains("cause 1:"));
745        assert!(output.contains("Underlying error"));
746    }
747
748    #[test]
749    fn test_render_result_with_warnings() {
750        let artifacts = vec![];
751        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
752
753        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
754
755        assert_eq!(result.warnings.len(), 1);
756        assert_eq!(result.warnings[0].message, "Test warning");
757    }
758
759    #[test]
760    fn test_minijinja_error_conversion() {
761        // Use undefined variable with strict mode to trigger an error
762        let template_str = "{{ undefined_var }}";
763        let mut env = minijinja::Environment::new();
764        env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
765
766        let result = env.render_str(template_str, minijinja::context! {});
767        assert!(
768            result.is_err(),
769            "Expected rendering to fail with undefined variable"
770        );
771
772        let minijinja_err = result.unwrap_err();
773        let render_err: RenderError = minijinja_err.into();
774
775        match render_err {
776            RenderError::TemplateFailed { diag } => {
777                assert_eq!(diag.severity, Severity::Error);
778                assert!(diag.code.is_some());
779                assert!(diag.hint.is_some());
780                assert!(diag.source.is_some());
781            }
782            _ => panic!("Expected TemplateFailed error"),
783        }
784    }
785}