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