1use crate::OutputFormat;
134
135pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
137
138pub const MAX_YAML_SIZE: usize = 1024 * 1024;
140
141pub const MAX_NESTING_DEPTH: usize = 100;
143
144pub const MAX_TEMPLATE_OUTPUT: usize = 50 * 1024 * 1024;
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149pub enum Severity {
150 Error,
152 Warning,
154 Note,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
160pub struct Location {
161 pub file: String,
163 pub line: u32,
165 pub col: u32,
167}
168
169#[derive(Debug, serde::Serialize)]
171pub struct Diagnostic {
172 pub severity: Severity,
174 pub code: Option<String>,
176 pub message: String,
178 pub primary: Option<Location>,
180 pub hint: Option<String>,
182 #[serde(skip)]
186 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
187}
188
189impl Diagnostic {
190 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 pub fn with_code(mut self, code: String) -> Self {
204 self.code = Some(code);
205 self
206 }
207
208 pub fn with_location(mut self, location: Location) -> Self {
210 self.primary = Some(location);
211 self
212 }
213
214 pub fn with_hint(mut self, hint: String) -> Self {
216 self.hint = Some(hint);
217 self
218 }
219
220 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 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 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 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
292pub struct SerializableDiagnostic {
293 pub severity: Severity,
295 pub code: Option<String>,
297 pub message: String,
299 pub primary: Option<Location>,
301 pub hint: Option<String>,
303 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#[derive(thiserror::Error, Debug)]
336pub enum ParseError {
337 #[error("Input too large: {size} bytes (max: {max} bytes)")]
339 InputTooLarge {
340 size: usize,
342 max: usize,
344 },
345
346 #[error("YAML parsing error: {0}")]
348 YamlError(#[from] serde_saphyr::Error),
349
350 #[error("JSON error: {0}")]
352 JsonError(#[from] serde_json::Error),
353
354 #[error("Invalid YAML structure: {0}")]
356 InvalidStructure(String),
357
358 #[error("{}", .diag.message)]
360 MissingCardDirective {
361 diag: Box<Diagnostic>,
363 },
364
365 #[error("{0}")]
367 Other(String),
368}
369
370impl ParseError {
371 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 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, },
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 } 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#[derive(thiserror::Error, Debug)]
439pub enum RenderError {
440 #[error("{diag}")]
442 EngineCreation {
443 diag: Box<Diagnostic>,
445 },
446
447 #[error("{diag}")]
449 InvalidFrontmatter {
450 diag: Box<Diagnostic>,
452 },
453
454 #[error("{diag}")]
456 TemplateFailed {
457 diag: Box<Diagnostic>,
459 },
460
461 #[error("Backend compilation failed with {} error(s)", diags.len())]
463 CompilationFailed {
464 diags: Vec<Diagnostic>,
466 },
467
468 #[error("{diag}")]
470 FormatNotSupported {
471 diag: Box<Diagnostic>,
473 },
474
475 #[error("{diag}")]
477 UnsupportedBackend {
478 diag: Box<Diagnostic>,
480 },
481
482 #[error("{diag}")]
484 DynamicAssetCollision {
485 diag: Box<Diagnostic>,
487 },
488
489 #[error("{diag}")]
491 DynamicFontCollision {
492 diag: Box<Diagnostic>,
494 },
495
496 #[error("{diag}")]
498 InputTooLarge {
499 diag: Box<Diagnostic>,
501 },
502
503 #[error("{diag}")]
505 YamlTooLarge {
506 diag: Box<Diagnostic>,
508 },
509
510 #[error("{diag}")]
512 NestingTooDeep {
513 diag: Box<Diagnostic>,
515 },
516
517 #[error("{diag}")]
519 OutputTooLarge {
520 diag: Box<Diagnostic>,
522 },
523
524 #[error("{diag}")]
526 ValidationFailed {
527 diag: Box<Diagnostic>,
529 },
530
531 #[error("{diag}")]
533 InvalidSchema {
534 diag: Box<Diagnostic>,
536 },
537
538 #[error("{diag}")]
540 QuillConfig {
541 diag: Box<Diagnostic>,
543 },
544}
545
546impl RenderError {
547 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#[derive(Debug)]
571pub struct RenderResult {
572 pub artifacts: Vec<crate::Artifact>,
574 pub warnings: Vec<Diagnostic>,
576 pub output_format: OutputFormat,
578}
579
580impl RenderResult {
581 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 pub fn with_warning(mut self, warning: Diagnostic) -> Self {
592 self.warnings.push(warning);
593 self
594 }
595}
596
597impl From<minijinja::Error> for RenderError {
599 fn from(e: minijinja::Error) -> Self {
600 let loc = e.line().map(|line| Location {
602 file: e.name().unwrap_or("template").to_string(),
603 line: line as u32,
604 col: e.range().map(|r| r.start as u32).unwrap_or(0),
606 });
607
608 let hint = generate_minijinja_hint(&e);
610
611 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 diag = diag.with_source(Box::new(e));
625
626 RenderError::TemplateFailed {
627 diag: Box::new(diag),
628 }
629 }
630}
631
632fn 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
650pub 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 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}