Skip to main content

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