Skip to main content

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}