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
147pub const MAX_YAML_DEPTH: usize = 100;
150
151pub const MAX_CARD_COUNT: usize = 1000;
154
155pub const MAX_FIELD_COUNT: usize = 1000;
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
161pub enum Severity {
162 Error,
164 Warning,
166 Note,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
172pub struct Location {
173 pub file: String,
175 pub line: u32,
177 pub col: u32,
179}
180
181#[derive(Debug, serde::Serialize)]
183pub struct Diagnostic {
184 pub severity: Severity,
186 pub code: Option<String>,
188 pub message: String,
190 pub primary: Option<Location>,
192 pub hint: Option<String>,
194 #[serde(skip)]
198 pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
199}
200
201impl Diagnostic {
202 pub fn new(severity: Severity, message: String) -> Self {
204 Self {
205 severity,
206 code: None,
207 message,
208 primary: None,
209 hint: None,
210 source: None,
211 }
212 }
213
214 pub fn with_code(mut self, code: String) -> Self {
216 self.code = Some(code);
217 self
218 }
219
220 pub fn with_location(mut self, location: Location) -> Self {
222 self.primary = Some(location);
223 self
224 }
225
226 pub fn with_hint(mut self, hint: String) -> Self {
228 self.hint = Some(hint);
229 self
230 }
231
232 pub fn with_source(mut self, source: Box<dyn std::error::Error + Send + Sync>) -> Self {
234 self.source = Some(source);
235 self
236 }
237
238 pub fn source_chain(&self) -> Vec<String> {
240 let mut chain = Vec::new();
241 let mut current_source = self
242 .source
243 .as_ref()
244 .map(|b| b.as_ref() as &dyn std::error::Error);
245 while let Some(err) = current_source {
246 chain.push(err.to_string());
247 current_source = err.source();
248 }
249 chain
250 }
251
252 pub fn fmt_pretty(&self) -> String {
254 let mut result = format!(
255 "[{}] {}",
256 match self.severity {
257 Severity::Error => "ERROR",
258 Severity::Warning => "WARN",
259 Severity::Note => "NOTE",
260 },
261 self.message
262 );
263
264 if let Some(ref code) = self.code {
265 result.push_str(&format!(" ({})", code));
266 }
267
268 if let Some(ref loc) = self.primary {
269 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.col));
270 }
271
272 if let Some(ref hint) = self.hint {
273 result.push_str(&format!("\n hint: {}", hint));
274 }
275
276 result
277 }
278
279 pub fn fmt_pretty_with_source(&self) -> String {
281 let mut result = self.fmt_pretty();
282
283 for (i, cause) in self.source_chain().iter().enumerate() {
284 result.push_str(&format!("\n cause {}: {}", i + 1, cause));
285 }
286
287 result
288 }
289}
290
291impl std::fmt::Display for Diagnostic {
292 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
293 write!(f, "{}", self.message)
294 }
295}
296
297#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
304pub struct SerializableDiagnostic {
305 pub severity: Severity,
307 pub code: Option<String>,
309 pub message: String,
311 pub primary: Option<Location>,
313 pub hint: Option<String>,
315 pub source_chain: Vec<String>,
317}
318
319impl From<Diagnostic> for SerializableDiagnostic {
320 fn from(diag: Diagnostic) -> Self {
321 let source_chain = diag.source_chain();
322 Self {
323 severity: diag.severity,
324 code: diag.code,
325 message: diag.message,
326 primary: diag.primary,
327 hint: diag.hint,
328 source_chain,
329 }
330 }
331}
332
333impl From<&Diagnostic> for SerializableDiagnostic {
334 fn from(diag: &Diagnostic) -> Self {
335 Self {
336 severity: diag.severity,
337 code: diag.code.clone(),
338 message: diag.message.clone(),
339 primary: diag.primary.clone(),
340 hint: diag.hint.clone(),
341 source_chain: diag.source_chain(),
342 }
343 }
344}
345
346#[derive(thiserror::Error, Debug)]
348pub enum ParseError {
349 #[error("Input too large: {size} bytes (max: {max} bytes)")]
351 InputTooLarge {
352 size: usize,
354 max: usize,
356 },
357
358 #[error("YAML parsing error: {0}")]
360 YamlError(#[from] serde_saphyr::Error),
361
362 #[error("JSON error: {0}")]
364 JsonError(#[from] serde_json::Error),
365
366 #[error("Invalid YAML structure: {0}")]
368 InvalidStructure(String),
369
370 #[error("{}", .diag.message)]
372 MissingCardDirective {
373 diag: Box<Diagnostic>,
375 },
376
377 #[error("YAML error at line {line}: {message}")]
379 YamlErrorWithLocation {
380 message: String,
382 line: usize,
384 block_index: usize,
386 },
387
388 #[error("{0}")]
390 Other(String),
391}
392
393impl ParseError {
394 pub fn missing_card_directive() -> Self {
396 let diag = Diagnostic::new(
397 Severity::Error,
398 "Inline metadata block missing CARD directive".to_string(),
399 )
400 .with_code("parse::missing_card".to_string())
401 .with_hint(
402 "Add 'CARD: <card_type>' to specify which card this block belongs to. \
403 Example:\n---\nCARD: my_card_type\nfield: value\n---"
404 .to_string(),
405 );
406 ParseError::MissingCardDirective {
407 diag: Box::new(diag),
408 }
409 }
410
411 pub fn to_diagnostic(&self) -> Diagnostic {
413 match self {
414 ParseError::MissingCardDirective { diag } => Diagnostic {
415 severity: diag.severity,
416 code: diag.code.clone(),
417 message: diag.message.clone(),
418 primary: diag.primary.clone(),
419 hint: diag.hint.clone(),
420 source: None, },
422 ParseError::InputTooLarge { size, max } => Diagnostic::new(
423 Severity::Error,
424 format!("Input too large: {} bytes (max: {} bytes)", size, max),
425 )
426 .with_code("parse::input_too_large".to_string()),
427 ParseError::YamlError(e) => {
428 Diagnostic::new(Severity::Error, format!("YAML parsing error: {}", e))
429 .with_code("parse::yaml_error".to_string())
430 } ParseError::JsonError(e) => {
432 Diagnostic::new(Severity::Error, format!("JSON conversion error: {}", e))
433 .with_code("parse::json_error".to_string())
434 }
435 ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
436 .with_code("parse::invalid_structure".to_string()),
437 ParseError::YamlErrorWithLocation {
438 message,
439 line,
440 block_index,
441 } => Diagnostic::new(
442 Severity::Error,
443 format!(
444 "YAML error at line {} (block {}): {}",
445 line, block_index, message
446 ),
447 )
448 .with_code("parse::yaml_error_with_location".to_string()),
449 ParseError::Other(msg) => Diagnostic::new(Severity::Error, msg.clone()),
450 }
451 }
452}
453
454impl From<Box<dyn std::error::Error + Send + Sync>> for ParseError {
455 fn from(err: Box<dyn std::error::Error + Send + Sync>) -> Self {
456 ParseError::Other(err.to_string())
457 }
458}
459
460impl From<String> for ParseError {
461 fn from(msg: String) -> Self {
462 ParseError::Other(msg)
463 }
464}
465
466impl From<&str> for ParseError {
467 fn from(msg: &str) -> Self {
468 ParseError::Other(msg.to_string())
469 }
470}
471
472#[derive(thiserror::Error, Debug)]
474pub enum RenderError {
475 #[error("{diag}")]
477 EngineCreation {
478 diag: Box<Diagnostic>,
480 },
481
482 #[error("{diag}")]
484 InvalidFrontmatter {
485 diag: Box<Diagnostic>,
487 },
488
489 #[error("{diag}")]
491 TemplateFailed {
492 diag: Box<Diagnostic>,
494 },
495
496 #[error("Backend compilation failed with {} error(s)", diags.len())]
498 CompilationFailed {
499 diags: Vec<Diagnostic>,
501 },
502
503 #[error("{diag}")]
505 FormatNotSupported {
506 diag: Box<Diagnostic>,
508 },
509
510 #[error("{diag}")]
512 UnsupportedBackend {
513 diag: Box<Diagnostic>,
515 },
516
517 #[error("{diag}")]
519 DynamicAssetCollision {
520 diag: Box<Diagnostic>,
522 },
523
524 #[error("{diag}")]
526 DynamicFontCollision {
527 diag: Box<Diagnostic>,
529 },
530
531 #[error("{diag}")]
533 InputTooLarge {
534 diag: Box<Diagnostic>,
536 },
537
538 #[error("{diag}")]
540 YamlTooLarge {
541 diag: Box<Diagnostic>,
543 },
544
545 #[error("{diag}")]
547 NestingTooDeep {
548 diag: Box<Diagnostic>,
550 },
551
552 #[error("{diag}")]
554 OutputTooLarge {
555 diag: Box<Diagnostic>,
557 },
558
559 #[error("{diag}")]
561 ValidationFailed {
562 diag: Box<Diagnostic>,
564 },
565
566 #[error("{diag}")]
568 InvalidSchema {
569 diag: Box<Diagnostic>,
571 },
572
573 #[error("{diag}")]
575 QuillConfig {
576 diag: Box<Diagnostic>,
578 },
579}
580
581impl RenderError {
582 pub fn diagnostics(&self) -> Vec<&Diagnostic> {
584 match self {
585 RenderError::CompilationFailed { diags } => diags.iter().collect(),
586 RenderError::EngineCreation { diag }
587 | RenderError::InvalidFrontmatter { diag }
588 | RenderError::TemplateFailed { diag }
589 | RenderError::FormatNotSupported { diag }
590 | RenderError::UnsupportedBackend { diag }
591 | RenderError::DynamicAssetCollision { diag }
592 | RenderError::DynamicFontCollision { diag }
593 | RenderError::InputTooLarge { diag }
594 | RenderError::YamlTooLarge { diag }
595 | RenderError::NestingTooDeep { diag }
596 | RenderError::OutputTooLarge { diag }
597 | RenderError::ValidationFailed { diag }
598 | RenderError::InvalidSchema { diag }
599 | RenderError::QuillConfig { diag } => vec![diag.as_ref()],
600 }
601 }
602}
603
604#[derive(Debug)]
606pub struct RenderResult {
607 pub artifacts: Vec<crate::Artifact>,
609 pub warnings: Vec<Diagnostic>,
611 pub output_format: OutputFormat,
613}
614
615impl RenderResult {
616 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
618 Self {
619 artifacts,
620 warnings: Vec::new(),
621 output_format,
622 }
623 }
624
625 pub fn with_warning(mut self, warning: Diagnostic) -> Self {
627 self.warnings.push(warning);
628 self
629 }
630}
631
632impl From<minijinja::Error> for RenderError {
634 fn from(e: minijinja::Error) -> Self {
635 let loc = e.line().map(|line| Location {
637 file: e.name().unwrap_or("template").to_string(),
638 line: line as u32,
639 col: e.range().map(|r| r.start as u32).unwrap_or(0),
641 });
642
643 let hint = generate_minijinja_hint(&e);
645
646 let mut diag = Diagnostic::new(Severity::Error, e.to_string())
648 .with_code(format!("minijinja::{:?}", e.kind()));
649
650 if let Some(loc) = loc {
651 diag = diag.with_location(loc);
652 }
653
654 if let Some(hint) = hint {
655 diag = diag.with_hint(hint);
656 }
657
658 diag = diag.with_source(Box::new(e));
660
661 RenderError::TemplateFailed {
662 diag: Box::new(diag),
663 }
664 }
665}
666
667fn generate_minijinja_hint(e: &minijinja::Error) -> Option<String> {
669 use minijinja::ErrorKind;
670
671 match e.kind() {
672 ErrorKind::UndefinedError => {
673 Some("Check variable spelling and ensure it's defined in frontmatter".to_string())
674 }
675 ErrorKind::InvalidOperation => {
676 Some("Check that you're using the correct filter or operator for this type".to_string())
677 }
678 ErrorKind::SyntaxError => Some(
679 "Check template syntax - look for unclosed tags or invalid expressions".to_string(),
680 ),
681 _ => e.detail().map(|d| d.to_string()),
682 }
683}
684
685pub fn print_errors(err: &RenderError) {
687 match err {
688 RenderError::CompilationFailed { diags } => {
689 for d in diags {
690 eprintln!("{}", d.fmt_pretty());
691 }
692 }
693 RenderError::TemplateFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
694 RenderError::InvalidFrontmatter { diag } => eprintln!("{}", diag.fmt_pretty()),
695 RenderError::EngineCreation { diag } => eprintln!("{}", diag.fmt_pretty()),
696 RenderError::FormatNotSupported { diag } => eprintln!("{}", diag.fmt_pretty()),
697 RenderError::UnsupportedBackend { diag } => eprintln!("{}", diag.fmt_pretty()),
698 RenderError::DynamicAssetCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
699 RenderError::DynamicFontCollision { diag } => eprintln!("{}", diag.fmt_pretty()),
700 RenderError::InputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
701 RenderError::YamlTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
702 RenderError::NestingTooDeep { diag } => eprintln!("{}", diag.fmt_pretty()),
703 RenderError::OutputTooLarge { diag } => eprintln!("{}", diag.fmt_pretty()),
704 RenderError::ValidationFailed { diag } => eprintln!("{}", diag.fmt_pretty()),
705 RenderError::InvalidSchema { diag } => eprintln!("{}", diag.fmt_pretty()),
706 RenderError::QuillConfig { diag } => eprintln!("{}", diag.fmt_pretty()),
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[test]
715 fn test_diagnostic_with_source_chain() {
716 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
717 let diag = Diagnostic::new(Severity::Error, "Rendering failed".to_string())
718 .with_source(Box::new(root_err));
719
720 let chain = diag.source_chain();
721 assert_eq!(chain.len(), 1);
722 assert!(chain[0].contains("File not found"));
723 }
724
725 #[test]
726 fn test_diagnostic_serialization() {
727 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
728 .with_code("E001".to_string())
729 .with_location(Location {
730 file: "test.typ".to_string(),
731 line: 10,
732 col: 5,
733 });
734
735 let serializable: SerializableDiagnostic = diag.into();
736 let json = serde_json::to_string(&serializable).unwrap();
737 assert!(json.contains("Test error"));
738 assert!(json.contains("E001"));
739 }
740
741 #[test]
742 fn test_render_error_diagnostics_extraction() {
743 let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
744 let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
745
746 let err = RenderError::CompilationFailed {
747 diags: vec![diag1, diag2],
748 };
749
750 let diags = err.diagnostics();
751 assert_eq!(diags.len(), 2);
752 }
753
754 #[test]
755 fn test_diagnostic_fmt_pretty() {
756 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
757 .with_code("W001".to_string())
758 .with_location(Location {
759 file: "input.md".to_string(),
760 line: 5,
761 col: 10,
762 })
763 .with_hint("Use the new field name instead".to_string());
764
765 let output = diag.fmt_pretty();
766 assert!(output.contains("[WARN]"));
767 assert!(output.contains("Deprecated field used"));
768 assert!(output.contains("W001"));
769 assert!(output.contains("input.md:5:10"));
770 assert!(output.contains("hint:"));
771 }
772
773 #[test]
774 fn test_diagnostic_fmt_pretty_with_source() {
775 let root_err = std::io::Error::other("Underlying error");
776 let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
777 .with_code("E002".to_string())
778 .with_source(Box::new(root_err));
779
780 let output = diag.fmt_pretty_with_source();
781 assert!(output.contains("[ERROR]"));
782 assert!(output.contains("Top-level error"));
783 assert!(output.contains("cause 1:"));
784 assert!(output.contains("Underlying error"));
785 }
786
787 #[test]
788 fn test_render_result_with_warnings() {
789 let artifacts = vec![];
790 let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
791
792 let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
793
794 assert_eq!(result.warnings.len(), 1);
795 assert_eq!(result.warnings[0].message, "Test warning");
796 }
797
798 #[test]
799 fn test_minijinja_error_conversion() {
800 let template_str = "{{ undefined_var }}";
802 let mut env = minijinja::Environment::new();
803 env.set_undefined_behavior(minijinja::UndefinedBehavior::Strict);
804
805 let result = env.render_str(template_str, minijinja::context! {});
806 assert!(
807 result.is_err(),
808 "Expected rendering to fail with undefined variable"
809 );
810
811 let minijinja_err = result.unwrap_err();
812 let render_err: RenderError = minijinja_err.into();
813
814 match render_err {
815 RenderError::TemplateFailed { diag } => {
816 assert_eq!(diag.severity, Severity::Error);
817 assert!(diag.code.is_some());
818 assert!(diag.hint.is_some());
819 assert!(diag.source.is_some());
820 }
821 _ => panic!("Expected TemplateFailed error"),
822 }
823 }
824}