quarto_error_reporting/builder.rs
1//! Builder API for diagnostic messages.
2//!
3//! This module provides a builder pattern that encodes tidyverse-style error message
4//! guidelines directly in the API, making it easy to construct well-structured error messages.
5
6use crate::diagnostic::{
7 DetailItem, DetailKind, DiagnosticKind, DiagnosticMessage, MessageContent,
8};
9
10/// Builder for creating diagnostic messages following tidyverse guidelines.
11///
12/// The builder API naturally encourages the tidyverse four-part error structure:
13/// 1. **Title**: Brief error message (via `.error()`, `.warning()`, etc.)
14/// 2. **Problem**: What went wrong - the "must" or "can't" statement (via `.problem()`)
15/// 3. **Details**: Specific information - max 5 bulleted items (via `.add_detail()`, `.add_info()`)
16/// 4. **Hints**: Optional guidance (via `.add_hint()`)
17///
18/// # Example
19///
20/// ```
21/// use quarto_error_reporting::DiagnosticMessageBuilder;
22///
23/// let error = DiagnosticMessageBuilder::error("Incompatible types")
24/// .with_code("Q-1-2") // quarto-error-code-audit-ignore
25/// .problem("Cannot combine date and datetime types")
26/// .add_detail("`x`{.arg} has type `date`{.type}")
27/// .add_detail("`y`{.arg} has type `datetime`{.type}")
28/// .add_hint("Convert both to the same type?")
29/// .build();
30///
31/// assert_eq!(error.title, "Incompatible types");
32/// assert_eq!(error.code, Some("Q-1-2".to_string())); // quarto-error-code-audit-ignore
33/// assert!(error.problem.is_some());
34/// assert_eq!(error.details.len(), 2);
35/// assert_eq!(error.hints.len(), 1);
36/// ```
37#[derive(Debug, Clone)]
38pub struct DiagnosticMessageBuilder {
39 /// The kind of diagnostic (Error, Warning, Info)
40 kind: DiagnosticKind,
41
42 /// Brief title for the error
43 title: String,
44
45 /// Optional error code (e.g., "Q-1-1") (quarto-error-code-audit-ignore)
46 code: Option<String>,
47
48 /// The problem statement (the "what")
49 problem: Option<MessageContent>,
50
51 /// Specific error details (the "where/why")
52 details: Vec<DetailItem>,
53
54 /// Optional hints for fixing
55 hints: Vec<MessageContent>,
56
57 /// Source location for this diagnostic
58 location: Option<quarto_source_map::SourceInfo>,
59}
60
61impl DiagnosticMessageBuilder {
62 /// Create a new builder with the specified kind and title.
63 ///
64 /// Most code should use the convenience methods `.error()`, `.warning()`, or `.info()`
65 /// instead of calling this directly.
66 pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
67 Self {
68 kind,
69 title: title.into(),
70 code: None,
71 problem: None,
72 details: Vec::new(),
73 hints: Vec::new(),
74 location: None,
75 }
76 }
77
78 /// Create an error diagnostic builder.
79 ///
80 /// # Example
81 ///
82 /// ```
83 /// use quarto_error_reporting::DiagnosticMessageBuilder;
84 ///
85 /// let error = DiagnosticMessageBuilder::error("YAML Syntax Error")
86 /// .build();
87 /// ```
88 pub fn error(title: impl Into<String>) -> Self {
89 Self::new(DiagnosticKind::Error, title)
90 }
91
92 /// Create a generic error for migration purposes.
93 ///
94 /// This is a convenience method for the migration from ErrorCollector to DiagnosticMessage.
95 /// It creates an error with code Q-0-99 (quarto-error-code-audit-ignore) and includes file/line information for tracking
96 /// where the error originated in the code.
97 ///
98 /// # Example
99 ///
100 /// ```
101 /// use quarto_error_reporting::DiagnosticMessageBuilder;
102 ///
103 /// let error = DiagnosticMessageBuilder::generic_error(
104 /// "Found unexpected attribute",
105 /// file!(),
106 /// line!()
107 /// );
108 /// assert_eq!(error.code, Some("Q-0-99".to_string())); // quarto-error-code-audit-ignore
109 /// assert!(error.title.contains("Found unexpected attribute"));
110 /// ```
111 pub fn generic_error(message: impl Into<String>, file: &str, line: u32) -> DiagnosticMessage {
112 let title = format!("{} (at {}:{})", message.into(), file, line);
113 Self::error(title).with_code("Q-0-99").build() // quarto-error-code-audit-ignore
114 }
115
116 /// Create a generic warning for migration purposes.
117 ///
118 /// Similar to `generic_error()` but for warnings.
119 ///
120 /// # Example
121 ///
122 /// ```
123 /// use quarto_error_reporting::DiagnosticMessageBuilder;
124 ///
125 /// let warning = DiagnosticMessageBuilder::generic_warning(
126 /// "Caption found without table",
127 /// file!(),
128 /// line!()
129 /// );
130 /// assert_eq!(warning.code, Some("Q-0-99".to_string()));
131 /// ```
132 pub fn generic_warning(message: impl Into<String>, file: &str, line: u32) -> DiagnosticMessage {
133 let title = format!("{} (at {}:{})", message.into(), file, line);
134 Self::warning(title).with_code("Q-0-99").build() // quarto-error-code-audit-ignore
135 }
136
137 /// Create a warning diagnostic builder.
138 ///
139 /// # Example
140 ///
141 /// ```
142 /// use quarto_error_reporting::DiagnosticMessageBuilder;
143 ///
144 /// let warning = DiagnosticMessageBuilder::warning("Deprecated feature")
145 /// .build();
146 /// ```
147 pub fn warning(title: impl Into<String>) -> Self {
148 Self::new(DiagnosticKind::Warning, title)
149 }
150
151 /// Create an info diagnostic builder.
152 ///
153 /// # Example
154 ///
155 /// ```
156 /// use quarto_error_reporting::DiagnosticMessageBuilder;
157 ///
158 /// let info = DiagnosticMessageBuilder::info("Processing complete")
159 /// .build();
160 /// ```
161 pub fn info(title: impl Into<String>) -> Self {
162 Self::new(DiagnosticKind::Info, title)
163 }
164
165 /// Set the error code.
166 ///
167 /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1"). (quarto-error-code-audit-ignore)
168 ///
169 /// # Example
170 ///
171 /// ```
172 /// use quarto_error_reporting::DiagnosticMessageBuilder;
173 ///
174 /// let error = DiagnosticMessageBuilder::error("YAML Syntax Error")
175 /// .with_code("Q-1-1") // quarto-error-code-audit-ignore
176 /// .build();
177 ///
178 /// assert_eq!(error.code, Some("Q-1-1".to_string())); // quarto-error-code-audit-ignore
179 /// ```
180 pub fn with_code(mut self, code: impl Into<String>) -> Self {
181 self.code = Some(code.into());
182 self
183 }
184
185 /// Attach a source location to this diagnostic.
186 ///
187 /// The location identifies where in the source code the issue occurred.
188 /// The location may track transformation history, allowing the error to be
189 /// mapped back through multiple processing steps to the original source file.
190 ///
191 /// # Example
192 ///
193 /// ```ignore
194 /// use quarto_error_reporting::DiagnosticMessageBuilder;
195 /// use quarto_source_map::{SourceInfo, SourceContext, FileId, Range, Location};
196 ///
197 /// let mut ctx = SourceContext::new();
198 /// let file_id = ctx.add_file("test.qmd".into(), Some("content".into()));
199 /// let range = Range {
200 /// start: Location { offset: 0, row: 0, column: 0 },
201 /// end: Location { offset: 7, row: 0, column: 7 },
202 /// };
203 /// let source_info = SourceInfo::original(file_id, range);
204 ///
205 /// let error = DiagnosticMessageBuilder::error("Parse error")
206 /// .with_location(source_info)
207 /// .build();
208 /// ```
209 pub fn with_location(mut self, location: quarto_source_map::SourceInfo) -> Self {
210 self.location = Some(location);
211 self
212 }
213
214 /// Set the problem statement.
215 ///
216 /// Following tidyverse guidelines, the problem statement should:
217 /// - Start with a general, concise statement
218 /// - Use "must" for requirements or "can't" for impossibilities
219 /// - Be specific about types/expectations
220 ///
221 /// # Example
222 ///
223 /// ```
224 /// use quarto_error_reporting::DiagnosticMessageBuilder;
225 ///
226 /// let error = DiagnosticMessageBuilder::error("Invalid input")
227 /// .problem("`n` must be a numeric vector, not a character vector")
228 /// .build();
229 /// ```
230 pub fn problem(mut self, stmt: impl Into<MessageContent>) -> Self {
231 self.problem = Some(stmt.into());
232 self
233 }
234
235 /// Add an error detail (displayed with error/cross bullet).
236 ///
237 /// Error details provide specific information about what went wrong.
238 /// Following tidyverse guidelines:
239 /// - Keep sentences short and specific
240 /// - Reveal location, name, or content of problematic input
241 /// - Limit to 5 total details (error + info) to avoid overwhelming users
242 ///
243 /// # Example
244 ///
245 /// ```
246 /// use quarto_error_reporting::DiagnosticMessageBuilder;
247 ///
248 /// let error = DiagnosticMessageBuilder::error("Incompatible lengths")
249 /// .add_detail("`x` has length 3")
250 /// .add_detail("`y` has length 5")
251 /// .build();
252 ///
253 /// assert_eq!(error.details.len(), 2);
254 /// ```
255 pub fn add_detail(mut self, detail: impl Into<MessageContent>) -> Self {
256 self.details.push(DetailItem {
257 kind: DetailKind::Error,
258 content: detail.into(),
259 location: None,
260 });
261 self
262 }
263
264 /// Add an error detail with a source location.
265 ///
266 /// This allows adding contextual information that points to specific locations
267 /// in the source code, creating rich multi-location error messages.
268 ///
269 /// # Example
270 ///
271 /// ```ignore
272 /// use quarto_error_reporting::DiagnosticMessageBuilder;
273 ///
274 /// let error = DiagnosticMessageBuilder::error("Mismatched brackets")
275 /// .add_detail_at("Opening bracket here", opening_location)
276 /// .add_detail_at("But no closing bracket found", end_location)
277 /// .build();
278 /// ```
279 pub fn add_detail_at(
280 mut self,
281 detail: impl Into<MessageContent>,
282 location: quarto_source_map::SourceInfo,
283 ) -> Self {
284 self.details.push(DetailItem {
285 kind: DetailKind::Error,
286 content: detail.into(),
287 location: Some(location),
288 });
289 self
290 }
291
292 /// Add an info detail (displayed with info bullet).
293 ///
294 /// Info details provide additional context or explanatory information.
295 ///
296 /// # Example
297 ///
298 /// ```
299 /// use quarto_error_reporting::DiagnosticMessageBuilder;
300 ///
301 /// let error = DiagnosticMessageBuilder::error("Missing file")
302 /// .add_detail("Could not find `config.yaml`")
303 /// .add_info("Default configuration will be used")
304 /// .build();
305 /// ```
306 pub fn add_info(mut self, info: impl Into<MessageContent>) -> Self {
307 self.details.push(DetailItem {
308 kind: DetailKind::Info,
309 content: info.into(),
310 location: None,
311 });
312 self
313 }
314
315 /// Add an info detail with a source location.
316 pub fn add_info_at(
317 mut self,
318 info: impl Into<MessageContent>,
319 location: quarto_source_map::SourceInfo,
320 ) -> Self {
321 self.details.push(DetailItem {
322 kind: DetailKind::Info,
323 content: info.into(),
324 location: Some(location),
325 });
326 self
327 }
328
329 /// Add a note detail (displayed with plain bullet).
330 ///
331 /// # Example
332 ///
333 /// ```
334 /// use quarto_error_reporting::DiagnosticMessageBuilder;
335 ///
336 /// let error = DiagnosticMessageBuilder::error("Parse error")
337 /// .add_note("This is an experimental feature")
338 /// .build();
339 /// ```
340 pub fn add_note(mut self, note: impl Into<MessageContent>) -> Self {
341 self.details.push(DetailItem {
342 kind: DetailKind::Note,
343 content: note.into(),
344 location: None,
345 });
346 self
347 }
348
349 /// Add a note detail with a source location.
350 pub fn add_note_at(
351 mut self,
352 note: impl Into<MessageContent>,
353 location: quarto_source_map::SourceInfo,
354 ) -> Self {
355 self.details.push(DetailItem {
356 kind: DetailKind::Note,
357 content: note.into(),
358 location: Some(location),
359 });
360 self
361 }
362
363 /// Add a faded detail with a source location.
364 ///
365 /// Rendered with the same dim grey colour Ariadne uses for unlabelled
366 /// source characters, so it visually "punches a hole" in any wider
367 /// label that also covers the same column range. Useful for excluding
368 /// block-quote prefixes or other prefix decorations from the highlight
369 /// of a multi-line span.
370 pub fn add_faded_at(
371 mut self,
372 content: impl Into<MessageContent>,
373 location: quarto_source_map::SourceInfo,
374 ) -> Self {
375 self.details.push(DetailItem {
376 kind: DetailKind::Faded,
377 content: content.into(),
378 location: Some(location),
379 });
380 self
381 }
382
383 /// Add a hint for fixing the error.
384 ///
385 /// Following tidyverse guidelines, hints should:
386 /// - Only be included when the problem source is clear and common
387 /// - Provide straightforward fix suggestions
388 /// - End with a question mark if suggesting action
389 ///
390 /// # Example
391 ///
392 /// ```
393 /// use quarto_error_reporting::DiagnosticMessageBuilder;
394 ///
395 /// let error = DiagnosticMessageBuilder::error("Function not found")
396 /// .problem("Could not find function `summarise()`")
397 /// .add_hint("Did you mean `summarize()`?")
398 /// .build();
399 ///
400 /// assert_eq!(error.hints.len(), 1);
401 /// ```
402 pub fn add_hint(mut self, hint: impl Into<MessageContent>) -> Self {
403 self.hints.push(hint.into());
404 self
405 }
406
407 /// Build the diagnostic message.
408 ///
409 /// This consumes the builder and returns the constructed `DiagnosticMessage`.
410 ///
411 /// # Example
412 ///
413 /// ```
414 /// use quarto_error_reporting::DiagnosticMessageBuilder;
415 ///
416 /// let error = DiagnosticMessageBuilder::error("Parse error")
417 /// .problem("Invalid syntax")
418 /// .build();
419 ///
420 /// assert_eq!(error.title, "Parse error");
421 /// ```
422 pub fn build(self) -> DiagnosticMessage {
423 DiagnosticMessage {
424 code: self.code,
425 title: self.title,
426 kind: self.kind,
427 problem: self.problem,
428 details: self.details,
429 hints: self.hints,
430 location: self.location,
431 }
432 }
433
434 /// Build with validation.
435 ///
436 /// This validates the message structure according to tidyverse guidelines:
437 /// - Warns if there's no problem statement (recommended but not required)
438 /// - Warns if there are more than 5 details (overwhelming for users)
439 /// - Future: Could check that hints end with '?'
440 ///
441 /// Returns warnings as a Vec of strings. An empty Vec means validation passed.
442 ///
443 /// # Example
444 ///
445 /// ```
446 /// use quarto_error_reporting::DiagnosticMessageBuilder;
447 ///
448 /// let (error, warnings) = DiagnosticMessageBuilder::error("Test error")
449 /// .build_with_validation();
450 ///
451 /// // Warns because there's no problem statement
452 /// assert!(!warnings.is_empty());
453 /// ```
454 pub fn build_with_validation(self) -> (DiagnosticMessage, Vec<String>) {
455 let mut warnings = Vec::new();
456
457 // Check for problem statement
458 if self.problem.is_none() {
459 warnings.push(
460 "Error message missing problem statement. \
461 Consider adding .problem() to explain what went wrong."
462 .to_string(),
463 );
464 }
465
466 // Check detail count (tidyverse recommends max 5)
467 if self.details.len() > 5 {
468 warnings.push(format!(
469 "Error message has {} details. Tidyverse guidelines recommend max 5 to avoid \
470 overwhelming users.",
471 self.details.len()
472 ));
473 }
474
475 (self.build(), warnings)
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_builder_error() {
485 let msg = DiagnosticMessageBuilder::error("Test error").build();
486 assert_eq!(msg.title, "Test error");
487 assert_eq!(msg.kind, DiagnosticKind::Error);
488 }
489
490 #[test]
491 fn test_builder_warning() {
492 let msg = DiagnosticMessageBuilder::warning("Test warning").build();
493 assert_eq!(msg.kind, DiagnosticKind::Warning);
494 }
495
496 #[test]
497 fn test_builder_info() {
498 let msg = DiagnosticMessageBuilder::info("Test info").build();
499 assert_eq!(msg.kind, DiagnosticKind::Info);
500 }
501
502 #[test]
503 fn test_builder_with_code() {
504 let msg = DiagnosticMessageBuilder::error("Test")
505 .with_code("Q-1-1")
506 .build();
507 assert_eq!(msg.code, Some("Q-1-1".to_string()));
508 }
509
510 #[test]
511 fn test_builder_problem() {
512 let msg = DiagnosticMessageBuilder::error("Test")
513 .problem("Something went wrong")
514 .build();
515 assert!(msg.problem.is_some());
516 assert_eq!(msg.problem.unwrap().as_str(), "Something went wrong");
517 }
518
519 #[test]
520 fn test_builder_details() {
521 let msg = DiagnosticMessageBuilder::error("Test")
522 .add_detail("Detail 1")
523 .add_info("Info 1")
524 .add_note("Note 1")
525 .build();
526
527 assert_eq!(msg.details.len(), 3);
528 assert_eq!(msg.details[0].kind, DetailKind::Error);
529 assert_eq!(msg.details[1].kind, DetailKind::Info);
530 assert_eq!(msg.details[2].kind, DetailKind::Note);
531 }
532
533 #[test]
534 fn test_builder_hints() {
535 let msg = DiagnosticMessageBuilder::error("Test")
536 .add_hint("Did you mean X?")
537 .add_hint("Try Y instead")
538 .build();
539
540 assert_eq!(msg.hints.len(), 2);
541 }
542
543 #[test]
544 fn test_builder_complete_message() {
545 let msg = DiagnosticMessageBuilder::error("Incompatible types")
546 .with_code("Q-1-2") // quarto-error-code-audit-ignore
547 .problem("Cannot combine date and datetime types")
548 .add_detail("`x` has type `date`")
549 .add_detail("`y` has type `datetime`")
550 .add_hint("Convert both to the same type?")
551 .build();
552
553 assert_eq!(msg.title, "Incompatible types");
554 assert_eq!(msg.code, Some("Q-1-2".to_string())); // quarto-error-code-audit-ignore
555 assert!(msg.problem.is_some());
556 assert_eq!(msg.details.len(), 2);
557 assert_eq!(msg.hints.len(), 1);
558 }
559
560 #[test]
561 fn test_builder_validation_no_problem() {
562 let (msg, warnings) = DiagnosticMessageBuilder::error("Test").build_with_validation();
563
564 assert_eq!(msg.title, "Test");
565 assert!(!warnings.is_empty());
566 assert!(warnings[0].contains("missing problem statement"));
567 }
568
569 #[test]
570 fn test_builder_validation_too_many_details() {
571 let (_msg, warnings) = DiagnosticMessageBuilder::error("Test")
572 .problem("Something wrong")
573 .add_detail("1")
574 .add_detail("2")
575 .add_detail("3")
576 .add_detail("4")
577 .add_detail("5")
578 .add_detail("6")
579 .build_with_validation();
580
581 assert!(!warnings.is_empty());
582 assert!(warnings[0].contains("6 details"));
583 assert!(warnings[0].contains("max 5"));
584 }
585
586 #[test]
587 fn test_builder_validation_passes() {
588 let (_msg, warnings) = DiagnosticMessageBuilder::error("Test")
589 .problem("Something wrong")
590 .add_detail("Detail")
591 .build_with_validation();
592
593 assert!(warnings.is_empty());
594 }
595}