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    /// Get the source chain as a list of error messages
235    pub fn source_chain(&self) -> Vec<String> {
236        let mut chain = Vec::new();
237        let mut current_source = self
238            .source
239            .as_ref()
240            .map(|b| b.as_ref() as &dyn std::error::Error);
241        while let Some(err) = current_source {
242            chain.push(err.to_string());
243            current_source = err.source();
244        }
245        chain
246    }
247
248    /// Format diagnostic for pretty printing
249    pub fn fmt_pretty(&self) -> String {
250        let mut result = format!(
251            "[{}] {}",
252            match self.severity {
253                Severity::Error => "ERROR",
254                Severity::Warning => "WARN",
255                Severity::Note => "NOTE",
256            },
257            self.message
258        );
259
260        if let Some(ref code) = self.code {
261            result.push_str(&format!(" ({})", code));
262        }
263
264        if let Some(ref loc) = self.primary {
265            result.push_str(&format!("\n  --> {}:{}:{}", loc.file, loc.line, loc.col));
266        }
267
268        if let Some(ref hint) = self.hint {
269            result.push_str(&format!("\n  hint: {}", hint));
270        }
271
272        result
273    }
274
275    /// Format diagnostic with source chain for debugging
276    pub fn fmt_pretty_with_source(&self) -> String {
277        let mut result = self.fmt_pretty();
278
279        for (i, cause) in self.source_chain().iter().enumerate() {
280            result.push_str(&format!("\n  cause {}: {}", i + 1, cause));
281        }
282
283        result
284    }
285}
286
287impl std::fmt::Display for Diagnostic {
288    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289        write!(f, "{}", self.message)
290    }
291}
292
293/// Serializable diagnostic for cross-language boundaries
294///
295/// This type is used when diagnostics need to be serialized and sent across
296/// FFI boundaries (e.g., Python, WASM). Unlike `Diagnostic`, it does not
297/// contain the non-serializable `source` field, but instead includes a
298/// flattened `source_chain` for display purposes.
299#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
300pub struct SerializableDiagnostic {
301    /// Error severity level
302    pub severity: Severity,
303    /// Optional error code (e.g., "E001", "typst::syntax")
304    pub code: Option<String>,
305    /// Human-readable error message
306    pub message: String,
307    /// Primary source location
308    pub primary: Option<Location>,
309    /// Optional hint for fixing the error
310    pub hint: Option<String>,
311    /// Source chain as list of strings (for display purposes)
312    pub source_chain: Vec<String>,
313}
314
315impl From<Diagnostic> for SerializableDiagnostic {
316    fn from(diag: Diagnostic) -> Self {
317        let source_chain = diag.source_chain();
318        Self {
319            severity: diag.severity,
320            code: diag.code,
321            message: diag.message,
322            primary: diag.primary,
323            hint: diag.hint,
324            source_chain,
325        }
326    }
327}
328
329impl From<&Diagnostic> for SerializableDiagnostic {
330    fn from(diag: &Diagnostic) -> Self {
331        Self {
332            severity: diag.severity,
333            code: diag.code.clone(),
334            message: diag.message.clone(),
335            primary: diag.primary.clone(),
336            hint: diag.hint.clone(),
337            source_chain: diag.source_chain(),
338        }
339    }
340}
341
342/// Error type for parsing operations
343#[derive(thiserror::Error, Debug)]
344pub enum ParseError {
345    /// Input too large
346    #[error("Input too large: {size} bytes (max: {max} bytes)")]
347    InputTooLarge {
348        /// Actual size
349        size: usize,
350        /// Maximum allowed size
351        max: usize,
352    },
353
354    /// YAML parsing error
355    #[error("YAML parsing error: {0}")]
356    YamlError(#[from] serde_saphyr::Error),
357
358    /// JSON parsing/conversion error
359    #[error("JSON error: {0}")]
360    JsonError(#[from] serde_json::Error),
361
362    /// Invalid YAML structure
363    #[error("Invalid YAML structure: {0}")]
364    InvalidStructure(String),
365
366    /// Missing CARD directive in inline metadata block
367    #[error("{}", .diag.message)]
368    MissingCardDirective {
369        /// Diagnostic information with hint
370        diag: Box<Diagnostic>,
371    },
372
373    /// YAML parsing error with location context
374    #[error("YAML error at line {line}: {message}")]
375    YamlErrorWithLocation {
376        /// Error message
377        message: String,
378        /// Line number in the source document (1-indexed)
379        line: usize,
380        /// Index of the metadata block (0-indexed)
381        block_index: usize,
382    },
383
384    /// Other parsing errors
385    #[error("{0}")]
386    Other(String),
387}
388
389impl ParseError {
390    /// Create a MissingCardDirective error with helpful hint
391    pub fn missing_card_directive() -> Self {
392        let diag = Diagnostic::new(
393            Severity::Error,
394            "Inline metadata block missing CARD directive".to_string(),
395        )
396        .with_code("parse::missing_card".to_string())
397        .with_hint(
398            "Add 'CARD: <card_type>' to specify which card this block belongs to. \
399            Example:\n---\nCARD: my_card_type\nfield: value\n---"
400                .to_string(),
401        );
402        ParseError::MissingCardDirective {
403            diag: Box::new(diag),
404        }
405    }
406
407    /// Convert the parse error into a structured diagnostic
408    pub fn to_diagnostic(&self) -> Diagnostic {
409        match self {
410            ParseError::MissingCardDirective { diag } => Diagnostic {
411                severity: diag.severity,
412                code: diag.code.clone(),
413                message: diag.message.clone(),
414                primary: diag.primary.clone(),
415                hint: diag.hint.clone(),
416                source: None, // Cannot clone trait object, but it's empty in this case usually
417            },
418            ParseError::InputTooLarge { size, max } => Diagnostic::new(
419                Severity::Error,
420                format!("Input too large: {} bytes (max: {} bytes)", size, max),
421            )
422            .with_code("parse::input_too_large".to_string()),
423            ParseError::YamlError(e) => {
424                Diagnostic::new(Severity::Error, format!("YAML parsing error: {}", e))
425                    .with_code("parse::yaml_error".to_string())
426            } // serde_saphyr::Error implements Error+Clone? No, usually Error is not Clone.
427            ParseError::JsonError(e) => {
428                Diagnostic::new(Severity::Error, format!("JSON conversion error: {}", e))
429                    .with_code("parse::json_error".to_string())
430            }
431            ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
432                .with_code("parse::invalid_structure".to_string()),
433            ParseError::YamlErrorWithLocation {
434                message,
435                line,
436                block_index,
437            } => Diagnostic::new(
438                Severity::Error,
439                format!(
440                    "YAML error at line {} (block {}): {}",
441                    line, block_index, message
442                ),
443            )
444            .with_code("parse::yaml_error_with_location".to_string()),
445            ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
446        }
447    }
448}
449
450impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
451    fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
452        ParseError::Other(err.to_string())
453    }
454}
455
456impl From<String> for ParseError {
457    fn from(msg: String) -> Self {
458        ParseError::Other(msg)
459    }
460}
461
462impl From<&str> for ParseError {
463    fn from(msg: &str) -> Self {
464        ParseError::Other(msg.to_string())
465    }
466}
467
468/// Main error type for rendering operations
469#[derive(thiserror::Error, Debug)]
470pub enum RenderError {
471    /// Failed to create rendering engine
472    #[error("{diag}")]
473    EngineCreation {
474        /// Diagnostic information
475        diag: Box<Diagnostic>,
476    },
477
478    /// Invalid YAML frontmatter in markdown document
479    #[error("{diag}")]
480    InvalidFrontmatter {
481        /// Diagnostic information
482        diag: Box<Diagnostic>,
483    },
484
485    /// Template rendering failed
486    #[error("{diag}")]
487    TemplateFailed {
488        /// Diagnostic information
489        diag: Box<Diagnostic>,
490    },
491
492    /// Backend compilation failed with one or more errors
493    #[error("Backend compilation failed with {} error(s)", diags.len())]
494    CompilationFailed {
495        /// List of diagnostics
496        diags: Vec<Diagnostic>,
497    },
498
499    /// Requested output format not supported by backend
500    #[error("{diag}")]
501    FormatNotSupported {
502        /// Diagnostic information
503        diag: Box<Diagnostic>,
504    },
505
506    /// Backend not registered with engine
507    #[error("{diag}")]
508    UnsupportedBackend {
509        /// Diagnostic information
510        diag: Box<Diagnostic>,
511    },
512
513    /// Dynamic asset filename collision
514    #[error("{diag}")]
515    DynamicAssetCollision {
516        /// Diagnostic information
517        diag: Box<Diagnostic>,
518    },
519
520    /// Dynamic font filename collision
521    #[error("{diag}")]
522    DynamicFontCollision {
523        /// Diagnostic information
524        diag: Box<Diagnostic>,
525    },
526
527    /// Input size limits exceeded
528    #[error("{diag}")]
529    InputTooLarge {
530        /// Diagnostic information
531        diag: Box<Diagnostic>,
532    },
533
534    /// YAML size exceeded maximum allowed
535    #[error("{diag}")]
536    YamlTooLarge {
537        /// Diagnostic information
538        diag: Box<Diagnostic>,
539    },
540
541    /// Nesting depth exceeded maximum allowed
542    #[error("{diag}")]
543    NestingTooDeep {
544        /// Diagnostic information
545        diag: Box<Diagnostic>,
546    },
547
548    /// Validation failed for parsed document
549    #[error("{diag}")]
550    ValidationFailed {
551        /// Diagnostic information
552        diag: Box<Diagnostic>,
553    },
554
555    /// Invalid schema definition
556    #[error("{diag}")]
557    InvalidSchema {
558        /// Diagnostic information
559        diag: Box<Diagnostic>,
560    },
561
562    /// Quill configuration error
563    #[error("{diag}")]
564    QuillConfig {
565        /// Diagnostic information
566        diag: Box<Diagnostic>,
567    },
568
569    /// Version not found
570    #[error("{diag}")]
571    VersionNotFound {
572        /// Diagnostic information
573        diag: Box<Diagnostic>,
574    },
575
576    /// Quill not found (name doesn't exist)
577    #[error("{diag}")]
578    QuillNotFound {
579        /// Diagnostic information
580        diag: Box<Diagnostic>,
581    },
582
583    /// Invalid version format
584    #[error("{diag}")]
585    InvalidVersion {
586        /// Diagnostic information
587        diag: Box<Diagnostic>,
588    },
589}
590
591impl RenderError {
592    /// Extract all diagnostics from this error
593    pub fn diagnostics(&self) -> Vec<&Diagnostic> {
594        match self {
595            RenderError::CompilationFailed { diags } => diags.iter().collect(),
596            RenderError::EngineCreation { diag }
597            | RenderError::InvalidFrontmatter { diag }
598            | RenderError::TemplateFailed { diag }
599            | RenderError::FormatNotSupported { diag }
600            | RenderError::UnsupportedBackend { diag }
601            | RenderError::DynamicAssetCollision { diag }
602            | RenderError::DynamicFontCollision { diag }
603            | RenderError::InputTooLarge { diag }
604            | RenderError::YamlTooLarge { diag }
605            | RenderError::NestingTooDeep { diag }
606            | RenderError::ValidationFailed { diag }
607            | RenderError::InvalidSchema { diag }
608            | RenderError::QuillConfig { diag }
609            | RenderError::VersionNotFound { diag }
610            | RenderError::QuillNotFound { diag }
611            | RenderError::InvalidVersion { diag } => vec![diag.as_ref()],
612        }
613    }
614}
615
616/// Convert ParseError to RenderError
617impl From<ParseError> for RenderError {
618    fn from(err: ParseError) -> Self {
619        RenderError::InvalidFrontmatter {
620            diag: Box::new(
621                Diagnostic::new(Severity::Error, err.to_string())
622                    .with_code("parse::error".to_string()),
623            ),
624        }
625    }
626}
627
628/// Result type containing artifacts and warnings
629#[derive(Debug)]
630pub struct RenderResult {
631    /// Generated output artifacts
632    pub artifacts: Vec<crate::Artifact>,
633    /// Non-fatal diagnostic messages
634    pub warnings: Vec<Diagnostic>,
635    /// Output format that was produced
636    pub output_format: OutputFormat,
637}
638
639impl RenderResult {
640    /// Create a new result with artifacts and output format
641    pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
642        Self {
643            artifacts,
644            warnings: Vec::new(),
645            output_format,
646        }
647    }
648
649    /// Add a warning to the result
650    pub fn with_warning(mut self, warning: Diagnostic) -> Self {
651        self.warnings.push(warning);
652        self
653    }
654}
655
656/// Helper to print structured errors
657pub fn print_errors(err: &RenderError) {
658    match err {
659        RenderError::CompilationFailed { diags } => {
660            for d in diags {
661                eprintln!("{}", d.fmt_pretty());
662            }
663        }
664        RenderError::TemplateFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
665        RenderError::InvalidFrontmatter { diag } => eprintln!("{}", diag.fmt_pretty()),
666        RenderError::EngineCreation { diag } => eprintln!("{}", diag.fmt_pretty()),
667        RenderError::FormatNotSupported { diag } => eprintln!("{}", diag.fmt_pretty()),
668        RenderError::UnsupportedBackend { diag } => eprintln!("{}", diag.fmt_pretty()),
669        RenderError::DynamicAssetCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
670        RenderError::DynamicFontCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
671        RenderError::InputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
672        RenderError::YamlTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
673        RenderError::NestingTooDeep { diag } => eprintln!("{}", diag.fmt_pretty()),
674        RenderError::ValidationFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
675        RenderError::InvalidSchema { diag } => eprintln!("{}", diag.fmt_pretty()),
676        RenderError::QuillConfig { diag } => eprintln!("{}", diag.fmt_pretty()),
677        RenderError::VersionNotFound { diag } => eprintln!("{}", diag.fmt_pretty()),
678        RenderError::QuillNotFound { diag } => eprintln!("{}", diag.fmt_pretty()),
679        RenderError::InvalidVersion { diag } => eprintln!("{}", diag.fmt_pretty()),
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    #[test]
688    fn test_diagnostic_with_source_chain() {
689        let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
690        let diag = Diagnostic::new(Severity::Error, "Rendering failed".to_string())
691            .with_source(Box::new(root_err));
692
693        let chain = diag.source_chain();
694        assert_eq!(chain.len(), 1);
695        assert!(chain[0].contains("File not found"));
696    }
697
698    #[test]
699    fn test_diagnostic_serialization() {
700        let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
701            .with_code("E001".to_string())
702            .with_location(Location {
703                file: "test.typ".to_string(),
704                line: 10,
705                col: 5,
706            });
707
708        let serializable: SerializableDiagnostic = diag.into();
709        let json = serde_json::to_string(&serializable).unwrap();
710        assert!(json.contains("Test error"));
711        assert!(json.contains("E001"));
712    }
713
714    #[test]
715    fn test_render_error_diagnostics_extraction() {
716        let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
717        let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
718
719        let err = RenderError::CompilationFailed {
720            diags: vec![diag1, diag2],
721        };
722
723        let diags = err.diagnostics();
724        assert_eq!(diags.len(), 2);
725    }
726
727    #[test]
728    fn test_diagnostic_fmt_pretty() {
729        let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
730            .with_code("W001".to_string())
731            .with_location(Location {
732                file: "input.md".to_string(),
733                line: 5,
734                col: 10,
735            })
736            .with_hint("Use the new field name instead".to_string());
737
738        let output = diag.fmt_pretty();
739        assert!(output.contains("[WARN]"));
740        assert!(output.contains("Deprecated field used"));
741        assert!(output.contains("W001"));
742        assert!(output.contains("input.md:5:10"));
743        assert!(output.contains("hint:"));
744    }
745
746    #[test]
747    fn test_diagnostic_fmt_pretty_with_source() {
748        let root_err = std::io::Error::other("Underlying error");
749        let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
750            .with_code("E002".to_string())
751            .with_source(Box::new(root_err));
752
753        let output = diag.fmt_pretty_with_source();
754        assert!(output.contains("[ERROR]"));
755        assert!(output.contains("Top-level error"));
756        assert!(output.contains("cause 1:"));
757        assert!(output.contains("Underlying error"));
758    }
759
760    #[test]
761    fn test_render_result_with_warnings() {
762        let artifacts = vec![];
763        let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
764
765        let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
766
767        assert_eq!(result.warnings.len(), 1);
768        assert_eq!(result.warnings[0].message, "Test warning");
769    }
770}