1use crate::OutputFormat;
40
41pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
43
44pub const MAX_YAML_SIZE: usize = 1024 * 1024;
46
47pub const MAX_NESTING_DEPTH: usize = 100;
49
50pub use crate::document::limits::MAX_YAML_DEPTH;
52
53pub const MAX_CARD_COUNT: usize = 1000;
55
56pub const MAX_FIELD_COUNT: usize = 1000;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60#[serde(rename_all = "lowercase")]
61pub enum Severity {
62 Error,
64 Warning,
66 Note,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "camelCase")]
72pub struct Location {
73 pub file: String,
75 pub line: u32,
77 pub column: u32,
79}
80
81#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
88#[serde(rename_all = "camelCase")]
89pub struct Diagnostic {
90 pub severity: Severity,
91 #[serde(skip_serializing_if = "Option::is_none", default)]
93 pub code: Option<String>,
94 pub message: String,
95 #[serde(skip_serializing_if = "Option::is_none", default)]
100 pub location: Option<Location>,
101 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub path: Option<String>,
108 #[serde(skip_serializing_if = "Option::is_none", default)]
109 pub hint: Option<String>,
110 #[serde(skip_serializing_if = "Vec::is_empty", default)]
112 pub source_chain: Vec<String>,
113}
114
115impl Diagnostic {
116 pub fn new(severity: Severity, message: String) -> Self {
117 Self {
118 severity,
119 code: None,
120 message,
121 location: None,
122 path: None,
123 hint: None,
124 source_chain: Vec::new(),
125 }
126 }
127
128 pub fn with_code(mut self, code: String) -> Self {
129 self.code = Some(code);
130 self
131 }
132
133 pub fn with_location(mut self, location: Location) -> Self {
134 self.location = Some(location);
135 self
136 }
137
138 pub fn with_path(mut self, path: String) -> Self {
142 self.path = Some(path);
143 self
144 }
145
146 pub fn with_hint(mut self, hint: String) -> Self {
147 self.hint = Some(hint);
148 self
149 }
150
151 pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
153 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
154 while let Some(err) = current {
155 self.source_chain.push(err.to_string());
156 current = err.source();
157 }
158 self
159 }
160
161 pub fn fmt_pretty(&self) -> String {
162 let mut result = format!(
163 "[{}] {}",
164 match self.severity {
165 Severity::Error => "ERROR",
166 Severity::Warning => "WARN",
167 Severity::Note => "NOTE",
168 },
169 self.message
170 );
171
172 if let Some(ref code) = self.code {
173 result.push_str(&format!(" ({})", code));
174 }
175
176 if let Some(ref loc) = self.location {
177 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
178 }
179
180 if let Some(ref path) = self.path {
181 result.push_str(&format!("\n at {}", path));
182 }
183
184 if let Some(ref hint) = self.hint {
185 result.push_str(&format!("\n hint: {}", hint));
186 }
187
188 result
189 }
190
191 pub fn fmt_pretty_with_source(&self) -> String {
193 let mut result = self.fmt_pretty();
194
195 for (i, cause) in self.source_chain.iter().enumerate() {
196 result.push_str(&format!("\n cause {}: {}", i + 1, cause));
197 }
198
199 result
200 }
201}
202
203impl std::fmt::Display for Diagnostic {
204 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
205 write!(f, "{}", self.message)
206 }
207}
208
209#[derive(thiserror::Error, Debug)]
210pub enum ParseError {
211 #[error("Input too large: {size} bytes (max: {max} bytes)")]
212 InputTooLarge { size: usize, max: usize },
213
214 #[error("Invalid YAML structure: {0}")]
215 InvalidStructure(String),
216
217 #[error("{0}")]
222 EmptyInput(String),
223
224 #[error("{0}")]
230 MissingQuill(String),
231
232 #[error("Invalid $quill reference '{value}': {reason}")]
236 InvalidQuillReference {
237 value: String,
238 reason: String,
240 },
241
242 #[error("YAML error at line {line}: {message}")]
243 YamlErrorWithLocation {
244 message: String,
245 line: usize,
247 block_index: usize,
249 hint: Option<String>,
253 },
254}
255
256impl ParseError {
257 pub fn to_diagnostic(&self) -> Diagnostic {
258 match self {
259 ParseError::InputTooLarge { size, max } => Diagnostic::new(
260 Severity::Error,
261 format!("Input too large: {} bytes (max: {} bytes)", size, max),
262 )
263 .with_code("parse::input_too_large".to_string()),
264 ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
265 .with_code("parse::invalid_structure".to_string()),
266 ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
267 .with_code("parse::empty_input".to_string()),
268 ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
269 .with_code("parse::missing_quill".to_string()),
270 ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
271 Severity::Error,
272 format!("Invalid $quill reference '{}': {}", value, reason),
273 )
274 .with_code("parse::invalid_quill_reference".to_string())
275 .with_hint(crate::version::quill_ref_hint().to_string()),
276 ParseError::YamlErrorWithLocation {
277 message,
278 line,
279 block_index,
280 hint,
281 } => {
282 let mut d = Diagnostic::new(
283 Severity::Error,
284 format!(
285 "YAML error at line {} (block {}): {}",
286 line, block_index, message
287 ),
288 )
289 .with_code("parse::yaml_error_with_location".to_string());
290 if let Some(h) = hint {
291 d = d.with_hint(h.clone());
292 }
293 d
294 }
295 }
296 }
297}
298
299#[derive(Debug)]
309pub enum RenderError {
310 EngineCreation {
312 diags: Vec<Diagnostic>,
314 },
315
316 InvalidPayload {
318 diags: Vec<Diagnostic>,
320 },
321
322 CompilationFailed {
324 diags: Vec<Diagnostic>,
326 },
327
328 FormatNotSupported {
330 diags: Vec<Diagnostic>,
332 },
333
334 UnsupportedBackend {
336 diags: Vec<Diagnostic>,
338 },
339
340 ValidationFailed {
345 diags: Vec<Diagnostic>,
347 },
348
349 QuillConfig {
352 diags: Vec<Diagnostic>,
354 },
355
356 QuillMismatch {
362 diags: Vec<Diagnostic>,
364 },
365}
366
367impl RenderError {
368 pub fn diagnostics(&self) -> &[Diagnostic] {
370 match self {
371 RenderError::EngineCreation { diags }
372 | RenderError::InvalidPayload { diags }
373 | RenderError::CompilationFailed { diags }
374 | RenderError::FormatNotSupported { diags }
375 | RenderError::UnsupportedBackend { diags }
376 | RenderError::ValidationFailed { diags }
377 | RenderError::QuillConfig { diags }
378 | RenderError::QuillMismatch { diags } => diags,
379 }
380 }
381
382 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
384 match self {
385 RenderError::EngineCreation { diags }
386 | RenderError::InvalidPayload { diags }
387 | RenderError::CompilationFailed { diags }
388 | RenderError::FormatNotSupported { diags }
389 | RenderError::UnsupportedBackend { diags }
390 | RenderError::ValidationFailed { diags }
391 | RenderError::QuillConfig { diags }
392 | RenderError::QuillMismatch { diags } => diags,
393 }
394 }
395}
396
397impl std::fmt::Display for RenderError {
398 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
399 match self {
400 RenderError::CompilationFailed { diags } => {
401 write!(
402 f,
403 "Backend compilation failed with {} error(s)",
404 diags.len()
405 )
406 }
407 RenderError::ValidationFailed { diags } => {
408 write!(f, "Validation failed with {} error(s)", diags.len())
409 }
410 RenderError::QuillConfig { diags } => {
411 write!(
412 f,
413 "Quill configuration failed with {} error(s)",
414 diags.len()
415 )
416 }
417 RenderError::EngineCreation { .. }
418 | RenderError::InvalidPayload { .. }
419 | RenderError::FormatNotSupported { .. }
420 | RenderError::UnsupportedBackend { .. }
421 | RenderError::QuillMismatch { .. } => match self.diagnostics().first() {
422 Some(d) => write!(f, "{}", d.message),
423 None => write!(f, "render error"),
424 },
425 }
426 }
427}
428
429impl std::error::Error for RenderError {}
430
431impl From<ParseError> for RenderError {
432 fn from(err: ParseError) -> Self {
433 RenderError::InvalidPayload {
434 diags: vec![Diagnostic::new(Severity::Error, err.to_string())
435 .with_code("parse::error".to_string())],
436 }
437 }
438}
439
440#[derive(Debug)]
441pub struct RenderResult {
442 pub artifacts: Vec<crate::Artifact>,
443 pub warnings: Vec<Diagnostic>,
444 pub output_format: OutputFormat,
445}
446
447impl RenderResult {
448 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
449 Self {
450 artifacts,
451 warnings: Vec::new(),
452 output_format,
453 }
454 }
455
456 pub fn with_warning(mut self, warning: Diagnostic) -> Self {
457 self.warnings.push(warning);
458 self
459 }
460}
461
462pub fn print_errors(err: &RenderError) {
463 for d in err.diagnostics() {
464 eprintln!("{}", d.fmt_pretty());
465 }
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn test_diagnostic_with_source_chain() {
474 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
475 let diag =
476 Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
477
478 assert_eq!(diag.source_chain.len(), 1);
479 assert!(diag.source_chain[0].contains("File not found"));
480 }
481
482 #[test]
483 fn test_diagnostic_serialization() {
484 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
485 .with_code("E001".to_string())
486 .with_location(Location {
487 file: "test.typ".to_string(),
488 line: 10,
489 column: 5,
490 });
491
492 let json = serde_json::to_string(&diag).unwrap();
493 assert!(json.contains("Test error"));
494 assert!(json.contains("E001"));
495 assert!(json.contains("\"severity\":\"error\""));
496 assert!(json.contains("\"column\":5"));
497 }
498
499 #[test]
500 fn test_render_error_diagnostics_extraction() {
501 let diag1 = Diagnostic::new(Severity::Error, "Error 1".to_string());
502 let diag2 = Diagnostic::new(Severity::Error, "Error 2".to_string());
503
504 let err = RenderError::CompilationFailed {
505 diags: vec![diag1, diag2],
506 };
507
508 let diags = err.diagnostics();
509 assert_eq!(diags.len(), 2);
510 }
511
512 #[test]
513 fn test_render_error_uniform_single_diagnostic_shape() {
514 let err = RenderError::UnsupportedBackend {
517 diags: vec![Diagnostic::new(
518 Severity::Error,
519 "no such backend".to_string(),
520 )],
521 };
522 assert_eq!(err.diagnostics().len(), 1);
523 assert_eq!(err.to_string(), "no such backend");
524
525 let owned = err.into_diagnostics();
526 assert_eq!(owned.len(), 1);
527 assert_eq!(owned[0].message, "no such backend");
528 }
529
530 #[test]
531 fn test_render_error_display_aggregates_multi_diagnostic() {
532 let err = RenderError::ValidationFailed {
533 diags: vec![
534 Diagnostic::new(Severity::Error, "a".to_string()),
535 Diagnostic::new(Severity::Error, "b".to_string()),
536 ],
537 };
538 assert_eq!(err.to_string(), "Validation failed with 2 error(s)");
539 }
540
541 #[test]
542 fn test_diagnostic_fmt_pretty() {
543 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
544 .with_code("W001".to_string())
545 .with_location(Location {
546 file: "input.md".to_string(),
547 line: 5,
548 column: 10,
549 })
550 .with_hint("Use the new field name instead".to_string());
551
552 let output = diag.fmt_pretty();
553 assert!(output.contains("[WARN]"));
554 assert!(output.contains("Deprecated field used"));
555 assert!(output.contains("W001"));
556 assert!(output.contains("input.md:5:10"));
557 assert!(output.contains("hint:"));
558 }
559
560 #[test]
561 fn test_diagnostic_with_path() {
562 let diag = Diagnostic::new(Severity::Error, "Missing field".to_string())
563 .with_code("validation::field_absent".to_string())
564 .with_path("cards.indorsement[0].signature_block".to_string());
565
566 assert_eq!(
567 diag.path.as_deref(),
568 Some("cards.indorsement[0].signature_block")
569 );
570
571 let json = serde_json::to_string(&diag).unwrap();
572 assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
573
574 let pretty = diag.fmt_pretty();
575 assert!(pretty.contains("at cards.indorsement[0].signature_block"));
576 }
577
578 #[test]
579 fn test_diagnostic_path_omitted_when_none() {
580 let diag = Diagnostic::new(Severity::Error, "No path".to_string());
581 let json = serde_json::to_string(&diag).unwrap();
582 assert!(!json.contains("\"path\""));
583 }
584
585 #[test]
586 fn test_diagnostic_fmt_pretty_with_source() {
587 let root_err = std::io::Error::other("Underlying error");
588 let diag = Diagnostic::new(Severity::Error, "Top-level error".to_string())
589 .with_code("E002".to_string())
590 .with_source(&root_err);
591
592 let output = diag.fmt_pretty_with_source();
593 assert!(output.contains("[ERROR]"));
594 assert!(output.contains("Top-level error"));
595 assert!(output.contains("cause 1:"));
596 assert!(output.contains("Underlying error"));
597 }
598
599 #[test]
600 fn test_render_result_with_warnings() {
601 let artifacts = vec![];
602 let warning = Diagnostic::new(Severity::Warning, "Test warning".to_string());
603
604 let result = RenderResult::new(artifacts, OutputFormat::Pdf).with_warning(warning);
605
606 assert_eq!(result.warnings.len(), 1);
607 assert_eq!(result.warnings[0].message, "Test warning");
608 }
609}