1use crate::OutputFormat;
42
43pub const MAX_INPUT_SIZE: usize = 10 * 1024 * 1024;
45
46pub const MAX_YAML_SIZE: usize = 1024 * 1024;
48
49pub use quillmark_content::MAX_NESTING_DEPTH;
54
55pub use crate::document::limits::MAX_YAML_DEPTH;
57
58pub const MAX_CARD_COUNT: usize = 1000;
60
61pub const MAX_FIELD_COUNT: usize = 1000;
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum Severity {
71 Error,
73 Warning,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct Location {
80 pub file: String,
82 pub line: u32,
84 pub column: u32,
86}
87
88#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
95#[serde(rename_all = "camelCase")]
96pub struct Diagnostic {
97 pub severity: Severity,
98 #[serde(skip_serializing_if = "Option::is_none", default)]
100 pub code: Option<String>,
101 pub message: String,
102 #[serde(skip_serializing_if = "Option::is_none", default)]
107 pub location: Option<Location>,
108 #[serde(skip_serializing_if = "Option::is_none", default)]
114 pub path: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none", default)]
116 pub hint: Option<String>,
117 #[serde(skip_serializing_if = "Vec::is_empty", default)]
119 pub source_chain: Vec<String>,
120}
121
122impl Diagnostic {
123 pub fn new(severity: Severity, message: String) -> Self {
124 Self {
125 severity,
126 code: None,
127 message,
128 location: None,
129 path: None,
130 hint: None,
131 source_chain: Vec::new(),
132 }
133 }
134
135 pub fn with_code(mut self, code: String) -> Self {
136 self.code = Some(code);
137 self
138 }
139
140 pub fn with_location(mut self, location: Location) -> Self {
141 self.location = Some(location);
142 self
143 }
144
145 pub fn with_path(mut self, path: String) -> Self {
149 self.path = Some(path);
150 self
151 }
152
153 pub fn with_hint(mut self, hint: String) -> Self {
154 self.hint = Some(hint);
155 self
156 }
157
158 pub fn with_source(mut self, source: &(dyn std::error::Error + 'static)) -> Self {
160 let mut current: Option<&(dyn std::error::Error + 'static)> = Some(source);
161 while let Some(err) = current {
162 self.source_chain.push(err.to_string());
163 current = err.source();
164 }
165 self
166 }
167
168 pub fn fmt_pretty(&self) -> String {
169 let mut result = format!(
170 "[{}] {}",
171 match self.severity {
172 Severity::Error => "ERROR",
173 Severity::Warning => "WARN",
174 },
175 self.message
176 );
177
178 if let Some(ref code) = self.code {
179 result.push_str(&format!(" ({})", code));
180 }
181
182 if let Some(ref loc) = self.location {
183 result.push_str(&format!("\n --> {}:{}:{}", loc.file, loc.line, loc.column));
184 }
185
186 if let Some(ref path) = self.path {
187 result.push_str(&format!("\n at {}", path));
188 }
189
190 if let Some(ref hint) = self.hint {
191 result.push_str(&format!("\n hint: {}", hint));
192 }
193
194 result
195 }
196
197}
198
199impl std::fmt::Display for Diagnostic {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 write!(f, "{}", self.message)
202 }
203}
204
205#[derive(thiserror::Error, Debug)]
206pub enum ParseError {
207 #[error("Input too large: {size} bytes (max: {max} bytes)")]
208 InputTooLarge { size: usize, max: usize },
209
210 #[error("Invalid YAML structure: {0}")]
211 InvalidStructure(String),
212
213 #[error("{0}")]
218 EmptyInput(String),
219
220 #[error("{0}")]
226 MissingQuill(String),
227
228 #[error("Invalid $quill reference '{value}': {reason}")]
232 InvalidQuillReference {
233 value: String,
234 reason: String,
236 },
237
238 #[error("{0}")]
242 BodyImport(String),
243
244 #[error("YAML error at line {line}: {message}")]
245 YamlErrorWithLocation {
246 message: String,
247 line: usize,
249 block_index: usize,
251 hint: Option<String>,
255 },
256}
257
258impl ParseError {
259 pub fn to_diagnostic(&self) -> Diagnostic {
260 match self {
261 ParseError::InputTooLarge { size, max } => Diagnostic::new(
262 Severity::Error,
263 format!("Input too large: {} bytes (max: {} bytes)", size, max),
264 )
265 .with_code("parse::input_too_large".to_string()),
266 ParseError::InvalidStructure(msg) => Diagnostic::new(Severity::Error, msg.clone())
267 .with_code("parse::invalid_structure".to_string()),
268 ParseError::EmptyInput(msg) => Diagnostic::new(Severity::Error, msg.clone())
269 .with_code("parse::empty_input".to_string()),
270 ParseError::MissingQuill(msg) => Diagnostic::new(Severity::Error, msg.clone())
271 .with_code("parse::missing_quill".to_string()),
272 ParseError::BodyImport(msg) => Diagnostic::new(Severity::Error, msg.clone())
273 .with_code("parse::body_import".to_string()),
274 ParseError::InvalidQuillReference { value, reason } => Diagnostic::new(
275 Severity::Error,
276 format!("Invalid $quill reference '{}': {}", value, reason),
277 )
278 .with_code("parse::invalid_quill_reference".to_string())
279 .with_hint(crate::version::quill_ref_hint().to_string()),
280 ParseError::YamlErrorWithLocation {
281 message,
282 line,
283 block_index,
284 hint,
285 } => {
286 let mut d = Diagnostic::new(
287 Severity::Error,
288 format!(
289 "YAML error at line {} (block {}): {}",
290 line, block_index, message
291 ),
292 )
293 .with_code("parse::yaml_error_with_location".to_string());
294 if let Some(h) = hint {
295 d = d.with_hint(h.clone());
296 }
297 d
298 }
299 }
300 }
301}
302
303#[derive(Debug)]
313pub struct RenderError {
314 diags: Vec<Diagnostic>,
316}
317
318impl RenderError {
319 pub fn new(diags: Vec<Diagnostic>) -> Self {
325 debug_assert!(
326 !diags.is_empty(),
327 "RenderError requires at least one diagnostic"
328 );
329 Self { diags }
330 }
331
332 pub fn from_diag(diag: Diagnostic) -> Self {
334 Self { diags: vec![diag] }
335 }
336
337 pub fn diagnostics(&self) -> &[Diagnostic] {
340 &self.diags
341 }
342
343 pub fn into_diagnostics(self) -> Vec<Diagnostic> {
345 self.diags
346 }
347
348 pub fn summary_message(diags: &[Diagnostic]) -> String {
355 match diags {
356 [d] => d.message.clone(),
357 [first, ..] => format!("{} error(s): {}", diags.len(), first.message),
358 [] => "render error".to_string(),
359 }
360 }
361}
362
363impl std::fmt::Display for RenderError {
367 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368 write!(f, "{}", Self::summary_message(&self.diags))
369 }
370}
371
372impl std::error::Error for RenderError {}
373
374impl From<ParseError> for RenderError {
375 fn from(err: ParseError) -> Self {
376 RenderError::from_diag(err.to_diagnostic())
377 }
378}
379
380#[derive(Debug)]
381pub struct RenderResult {
382 pub artifacts: Vec<crate::Artifact>,
383 pub warnings: Vec<Diagnostic>,
384 pub output_format: OutputFormat,
385 pub regions: Vec<crate::RenderedRegion>,
391}
392
393impl RenderResult {
394 pub fn new(artifacts: Vec<crate::Artifact>, output_format: OutputFormat) -> Self {
395 Self {
396 artifacts,
397 warnings: Vec::new(),
398 output_format,
399 regions: Vec::new(),
400 }
401 }
402}
403
404pub fn print_errors(err: &RenderError) {
405 for d in err.diagnostics() {
406 eprintln!("{}", d.fmt_pretty());
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn test_diagnostic_with_source_chain() {
416 let root_err = std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
417 let diag =
418 Diagnostic::new(Severity::Error, "Rendering failed".to_string()).with_source(&root_err);
419
420 assert_eq!(diag.source_chain.len(), 1);
421 assert!(diag.source_chain[0].contains("File not found"));
422 }
423
424 #[test]
425 fn test_diagnostic_serialization() {
426 let diag = Diagnostic::new(Severity::Error, "Test error".to_string())
427 .with_code("E001".to_string())
428 .with_location(Location {
429 file: "test.typ".to_string(),
430 line: 10,
431 column: 5,
432 });
433
434 let json = serde_json::to_string(&diag).unwrap();
435 assert!(json.contains("Test error"));
436 assert!(json.contains("E001"));
437 assert!(json.contains("\"severity\":\"error\""));
438 assert!(json.contains("\"column\":5"));
439 }
440
441 #[test]
442 fn test_render_error_single_diagnostic_shape() {
443 let err = RenderError::from_diag(Diagnostic::new(
444 Severity::Error,
445 "no such backend".to_string(),
446 ));
447 assert_eq!(err.diagnostics().len(), 1);
448 assert_eq!(err.to_string(), "no such backend");
449
450 let owned = err.into_diagnostics();
451 assert_eq!(owned.len(), 1);
452 assert_eq!(owned[0].message, "no such backend");
453 }
454
455 #[test]
456 fn test_render_error_display_aggregates_multi_diagnostic() {
457 let err = RenderError::new(vec![
458 Diagnostic::new(Severity::Error, "a".to_string()),
459 Diagnostic::new(Severity::Error, "b".to_string()),
460 ]);
461 assert_eq!(err.to_string(), "2 error(s): a");
462 }
463
464 #[test]
465 fn test_diagnostic_fmt_pretty() {
466 let diag = Diagnostic::new(Severity::Warning, "Deprecated field used".to_string())
467 .with_code("W001".to_string())
468 .with_location(Location {
469 file: "input.md".to_string(),
470 line: 5,
471 column: 10,
472 })
473 .with_hint("Use the new field name instead".to_string());
474
475 let output = diag.fmt_pretty();
476 assert!(output.contains("[WARN]"));
477 assert!(output.contains("Deprecated field used"));
478 assert!(output.contains("W001"));
479 assert!(output.contains("input.md:5:10"));
480 assert!(output.contains("hint:"));
481 }
482
483 #[test]
484 fn test_diagnostic_with_path() {
485 let diag = Diagnostic::new(Severity::Error, "Type mismatch".to_string())
486 .with_code("validation::type_mismatch".to_string())
487 .with_path("cards.indorsement[0].signature_block".to_string());
488
489 assert_eq!(
490 diag.path.as_deref(),
491 Some("cards.indorsement[0].signature_block")
492 );
493
494 let json = serde_json::to_string(&diag).unwrap();
495 assert!(json.contains("\"path\":\"cards.indorsement[0].signature_block\""));
496
497 let pretty = diag.fmt_pretty();
498 assert!(pretty.contains("at cards.indorsement[0].signature_block"));
499 }
500
501}