1use crate::OutputFormat;
20
21pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
23
24pub const MAX_YAML_SIZE: usize = 1024 * 1024;
26
27pub use quillmark_content::MAX_NESTING_DEPTH;
32
33pub use crate::document::limits::MAX_YAML_DEPTH;
35
36pub const MAX_CARD_COUNT: usize = 1000;
38
39pub const MAX_FIELD_COUNT: usize = 1000;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct YamlError {
55 message: String,
56 hint: Option<String>,
57 line: Option<u32>,
58 column: Option<u32>,
59}
60
61impl YamlError {
62 pub fn message(&self) -> &str {
64 &self.message
65 }
66
67 pub fn hint(&self) -> Option<&str> {
69 self.hint.as_deref()
70 }
71
72 pub fn line(&self) -> Option<u32> {
74 self.line
75 }
76
77 pub fn column(&self) -> Option<u32> {
79 self.column
80 }
81
82 pub fn to_diagnostic(&self, code: &str, file: &str) -> Diagnostic {
85 let mut diag = Diagnostic::new(Severity::Error, self.message.clone())
86 .with_code(code.to_string());
87 if let (Some(line), Some(column)) = (self.line, self.column) {
88 diag = diag.with_location(Location::new(file.to_string(), line, column));
89 }
90 match &self.hint {
91 Some(h) => diag.with_hint(h.clone()),
92 None => diag,
93 }
94 }
95
96 pub(crate) fn from_de(err: serde_saphyr::Error, yaml: &str) -> Self {
99 let enriched = crate::document::yaml_hints::enrich_yaml_error(&err.to_string(), yaml);
104 let loc = err.location();
107 Self {
108 message: enriched.message,
109 hint: enriched.hint,
110 line: loc.and_then(|l| u32::try_from(l.line()).ok()),
111 column: loc.and_then(|l| u32::try_from(l.column()).ok()),
112 }
113 }
114
115 pub(crate) fn from_ser(err: serde_saphyr::ser::Error) -> Self {
117 Self {
118 message: err.to_string(),
119 hint: None,
120 line: None,
121 column: None,
122 }
123 }
124}
125
126impl std::fmt::Display for YamlError {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.write_str(&self.message)
132 }
133}
134
135impl std::error::Error for YamlError {}
136
137#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
149#[serde(rename_all = "lowercase")]
150#[non_exhaustive]
151pub enum Severity {
152 Error,
154 Warning,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
159#[serde(rename_all = "camelCase")]
160#[non_exhaustive]
161pub struct Location {
162 pub file: String,
164 pub line: u32,
166 pub column: u32,
168}
169
170impl Location {
171 pub fn new(file: String, line: u32, column: u32) -> Self {
174 Self { file, line, column }
175 }
176}
177
178#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
185#[serde(rename_all = "camelCase")]
186#[non_exhaustive]
187pub struct Diagnostic {
188 pub severity: Severity,
189 #[serde(skip_serializing_if = "Option::is_none", default)]
191 pub code: Option<String>,
192 pub message: String,
193 #[serde(skip_serializing_if = "Option::is_none", default)]
198 pub location: Option<Location>,
199 #[serde(skip_serializing_if = "Option::is_none", default)]
205 pub path: Option<String>,
206 #[serde(skip_serializing_if = "Option::is_none", default)]
207 pub hint: Option<String>,
208 #[serde(skip_serializing_if = "Vec::is_empty", default)]
210 pub source_chain: Vec<String>,
211}
212
213impl Diagnostic {
214 pub fn new(severity: Severity, message: String) -> Self {
215 Self {
216 severity,
217 code: None,
218 message,
219 location: None,
220 path: None,
221 hint: None,
222 source_chain: Vec::new(),
223 }
224 }
225
226 pub fn with_code(mut self, code: String) -> Self {
227 self.code = Some(code);
228 self
229 }
230
231 pub fn with_location(mut self, location: Location) -> Self {
232 self.location = Some(location);
233 self
234 }
235
236 pub fn with_path(mut self, path: String) -> Self {
240 self.path = Some(path);
241 self
242 }
243
244 pub fn with_hint(mut self, hint: String) -> Self {
245 self.hint = Some(hint);
246 self
247 }
248
249 pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
251 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
252 while let Some(err) = current {
253 self.source_chain.push(err.to_string());
254 current = err.source();
255 }
256 self
257 }
258
259 pub fn fmt_pretty(&self) -> String {
260 let mut result = format!(
261 "[{}] {}",
262 match self.severity {
263 Severity::Error => "ERROR",
264 Severity::Warning => "WARN",
265 },
266 self.message
267 );
268
269 if let Some(ref code) = self.code {
270 result.push_str(&format!(" ({})", code));
271 }
272
273 if let Some(ref loc) = self.location {
274 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
275 }
276
277 if let Some(ref path) = self.path {
278 result.push_str(&format!("\n at {}", path));
279 }
280
281 if let Some(ref hint) = self.hint {
282 result.push_str(&format!("\n hint: {}", hint));
283 }
284
285 result
286 }
287
288}
289
290impl std::fmt::Display for Diagnostic {
291 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
292 write!(f, "{}", self.message)
293 }
294}
295
296#[derive(thiserror::Error, Debug)]
297#[non_exhaustive]
298pub enum ParseError {
299 #[error("Input too large: {size} bytes (max: {max} bytes)")]
300 InputTooLarge { size: usize, max: usize },
301
302 #[error("Invalid YAML structure: {0}")]
303 InvalidStructure(String),
304
305 #[error("{0}")]
310 EmptyInput(String),
311
312 #[error("{0}")]
318 MissingQuill(String),
319
320 #[error("Invalid $quill reference '{value}': {reason}")]
324 InvalidQuillReference {
325 value: String,
326 reason: String,
328 },
329
330 #[error("{0}")]
334 BodyImport(String),
335
336 #[error("YAML error at line {line}: {message}")]
337 YamlErrorWithLocation {
338 message: String,
339 line: usize,
341 block_index: usize,
343 hint: Option<String>,
347 },
348}
349
350impl ParseError {
351 pub fn to_diagnostic(&self) -> Diagnostic {
352 match self {
353 ParseError::InputTooLarge { size, max } => Diagnostic::new(
354 Severity::Error,
355 format!("Input too large: {} bytes (max: {} bytes)", size, max),
356 )
357 .with_code("parse::input_too_large".to_string()),
358 ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
359 .with_code("parse::invalid_structure".to_string()),
360 ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
361 .with_code("parse::empty_input".to_string()),
362 ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
363 .with_code("parse::missing_quill".to_string()),
364 ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
365 .with_code("parse::body_import".to_string()),
366 ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
367 Severity::Error,
368 format!("Invalid $quill reference '{}': {}", value, reason),
369 )
370 .with_code("parse::invalid_quill_reference".to_string())
371 .with_hint(crate::version::quill_ref_hint().to_string()),
372 ParseError::YamlErrorWithLocation {
373 message,
374 line,
375 block_index,
376 hint,
377 } => {
378 let mut d = Diagnostic::new(
379 Severity::Error,
380 format!(
381 "YAML error at line {} (block {}): {}",
382 line, block_index, message
383 ),
384 )
385 .with_code("parse::yaml_error_with_location".to_string());
386 if let Some(h) = hint {
387 d = d.with_hint(h.clone());
388 }
389 d
390 }
391 }
392 }
393}
394
395#[derive(Debug)]
405pub struct RenderError {
406 diags: Vec<Diagnostic>,
408}
409
410impl RenderError {
411 pub fn new(diags: Vec<Diagnostic>) -> Self {
417 debug_assert!(
418 !diags.is_empty(),
419 "RenderError requires at least one diagnostic"
420 );
421 Self { diags }
422 }
423
424 pub fn from_diag(diag: Diagnostic) -> Self {
426 Self { diags: vec![diag] }
427 }
428
429 pub fn diagnostics(&self) -> &[Diagnostic] {
432 &self.diags
433 }
434
435 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
437 self.diags
438 }
439
440 pub fn summary_message(diags: &[Diagnostic]) -> String {
447 match diags {
448 [d] => d.message.clone(),
449 [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
450 [] => "render error".to_string(),
451 }
452 }
453}
454
455impl std::fmt::Display for RenderError {
459 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
460 write!(f, "{}", Self::summary_message(&self.diags))
461 }
462}
463
464impl std::error::Error for RenderError {}
465
466impl From<ParseError> for RenderError {
467 fn from(err: ParseError) -> Self {
468 RenderError::from_diag(err.to_diagnostic())
469 }
470}
471
472#[derive(Debug)]
473#[non_exhaustive]
474pub struct RenderResult {
475 pub artifacts: Vec<crate::Artifact>,
476 pub warnings: Vec<Diagnostic>,
477 pub output_format: OutputFormat,
478 pub regions: Vec<crate::RenderedRegion>,
484}
485
486impl RenderResult {
487 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
488 Self {
489 artifacts,
490 warnings: Vec::new(),
491 output_format,
492 regions: Vec::new(),
493 }
494 }
495}
496
497pub fn print_errors(err: &RenderError) {
498 for d in err.diagnostics() {
499 eprintln!("{}", d.fmt_pretty());
500 }
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 #[test]
508 fn test_diagnostic_with_source_chain() {
509 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
510 let diag =
511 Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
512
513 assert_eq!(diag.source_chain.len(), 1);
514 assert!(diag.source_chain[0].contains("File not found"));
515 }
516
517 #[test]
518 fn test_diagnostic_serialization() {
519 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
520 .with_code("E001".to_string())
521 .with_location(Location {
522 file: "test.typ".to_string(),
523 line: 10,
524 column: 5,
525 });
526
527 let json = serde_json::to_string(&diag).unwrap();
528 assert!(json.contains("Test error"));
529 assert!(json.contains("E001"));
530 assert!(json.contains("\"severity\":\"error\""));
531 assert!(json.contains("\"column\":5"));
532 }
533
534 #[test]
535 fn test_render_error_single_diagnostic_shape() {
536 let err = RenderError::from_diag(Diagnostic::new(
537 Severity::Error,
538 "no such backend".to_string(),
539 ));
540 assert_eq!(err.diagnostics().len(), 1);
541 assert_eq!(err.to_string(), "no such backend");
542
543 let owned = err.into_diagnostics();
544 assert_eq!(owned.len(), 1);
545 assert_eq!(owned[0].message, "no such backend");
546 }
547
548 #[test]
549 fn test_render_error_display_aggregates_multi_diagnostic() {
550 let err = RenderError::new(vec![
551 Diagnostic::new(Severity::Error, "a".to_string()),
552 Diagnostic::new(Severity::Error, "b".to_string()),
553 ]);
554 assert_eq!(err.to_string(), "2 error(s): a");
555 }
556
557 #[test]
558 fn test_diagnostic_fmt_pretty() {
559 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
560 .with_code("W001".to_string())
561 .with_location(Location {
562 file: "input.md".to_string(),
563 line: 5,
564 column: 10,
565 })
566 .with_hint("Use the new field name instead".to_string());
567
568 let output = diag.fmt_pretty();
569 assert!(output.contains("[WARN]"));
570 assert!(output.contains("Deprecated field used"));
571 assert!(output.contains("W001"));
572 assert!(output.contains("input.md:5:10"));
573 assert!(output.contains("hint:"));
574 }
575
576 #[test]
577 fn test_diagnostic_with_path() {
578 let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
579 .with_code("validation::type_mismatch".to_string())
580 .with_path("cards.indorsement[0].signature_block".to_string());
581
582 assert_eq!(
583 diag.path.as_deref(),
584 Some("cards.indorsement[0].signature_block")
585 );
586
587 let json = serde_json::to_string(&diag).unwrap();
588 assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
589
590 let pretty = diag.fmt_pretty();
591 assert!(pretty.contains("at cards.indorsement[0].signature_block"));
592 }
593
594}