Skip to main content

quarto_error_reporting/
diagnostic.rs

1//! Core diagnostic message types.
2//!
3//! This module defines the fundamental structures for representing diagnostic messages
4//! (errors, warnings, info) following tidyverse-style guidelines.
5
6use serde::{Deserialize, Serialize};
7
8/// The kind of diagnostic message.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum DiagnosticKind {
11    /// An error that prevents completion
12    Error,
13    /// A warning that doesn't prevent completion but indicates a problem
14    Warning,
15    /// Informational message
16    Info,
17    /// A note providing additional context
18    Note,
19}
20
21/// How detail items should be presented (tidyverse x/i bullet style).
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23pub enum DetailKind {
24    /// Error detail (✖ bullet in tidyverse style)
25    Error,
26    /// Info detail (i bullet in tidyverse style)
27    Info,
28    /// Note detail (plain bullet)
29    Note,
30    /// Faded detail — rendered in Ariadne with the same dim grey colour
31    /// Ariadne uses for source characters outside any label. Use it to
32    /// attach a high-priority label to a column range you want to
33    /// *exclude* from a wider label's highlighting (e.g. a block-quote
34    /// prefix inside a multi-line span). Treated the same as `Note` in
35    /// tidyverse-style text output.
36    Faded,
37}
38
39/// Options for rendering diagnostic messages to text.
40///
41/// This struct controls various aspects of text rendering, such as whether
42/// to include terminal hyperlinks for clickable file paths.
43#[derive(Debug, Clone)]
44pub struct TextRenderOptions {
45    /// Enable OSC 8 hyperlinks for clickable file paths in terminals.
46    ///
47    /// When enabled, file paths in error messages will include terminal
48    /// escape codes for clickable links (supported by iTerm2, VS Code, etc.).
49    /// Disable for snapshot testing to avoid absolute path differences.
50    pub enable_hyperlinks: bool,
51}
52
53impl Default for TextRenderOptions {
54    fn default() -> Self {
55        Self {
56            enable_hyperlinks: true,
57        }
58    }
59}
60
61/// Selects which source-context snippet renderer draws the visual code
62/// excerpt in [`DiagnosticMessage::to_text_with_renderer`].
63///
64/// The available variants depend on which renderer features are enabled
65/// at compile time, so this enum is `#[non_exhaustive]`: with neither
66/// `ariadne` nor `annotate-snippets` enabled it has no variants at all,
67/// and downstream `match`es must include a wildcard arm to stay
68/// compiling across feature combinations.
69///
70/// Pass `None` to [`DiagnosticMessage::to_text_with_renderer`] (or use
71/// [`DiagnosticMessage::to_text`] / [`DiagnosticMessage::to_text_with_options`])
72/// to let the crate pick a default via [`SourceRenderer::default_for_features`].
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum SourceRenderer {
76    /// [ariadne](https://crates.io/crates/ariadne)-style rendering: a
77    /// boxed source excerpt. Available with the `ariadne` feature (on by
78    /// default).
79    #[cfg(feature = "ariadne")]
80    Ariadne,
81    /// [annotate-snippets](https://crates.io/crates/annotate-snippets)-style
82    /// rendering: the rust-lang toolchain's `-->` / gutter-bar look.
83    /// Available with the `annotate-snippets` feature.
84    #[cfg(feature = "annotate-snippets")]
85    AnnotateSnippets,
86}
87
88impl SourceRenderer {
89    /// The renderer used when the caller does not specify one.
90    ///
91    /// Prefers [`SourceRenderer::Ariadne`] when the `ariadne` feature is
92    /// enabled (preserving historical behavior), then falls back to
93    /// [`SourceRenderer::AnnotateSnippets`]. Returns `None` when no
94    /// renderer feature is enabled, in which case `to_text` drops the
95    /// source-context snippet and prints the structured text block.
96    pub fn default_for_features() -> Option<Self> {
97        // Exactly one of these `#[cfg]` blocks survives in any feature
98        // configuration, so the surviving block is the function's tail
99        // expression — no `return` and no unreachable code.
100        #[cfg(feature = "ariadne")]
101        {
102            Some(Self::Ariadne)
103        }
104        #[cfg(all(not(feature = "ariadne"), feature = "annotate-snippets"))]
105        {
106            Some(Self::AnnotateSnippets)
107        }
108        #[cfg(all(not(feature = "ariadne"), not(feature = "annotate-snippets")))]
109        {
110            None
111        }
112    }
113}
114
115/// The content of a message or detail item.
116///
117/// This will eventually support Pandoc AST for rich formatting, but starts
118/// with simpler string-based content.
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
120pub enum MessageContent {
121    /// Plain text content
122    Plain(String),
123    /// Markdown content (will be parsed to Pandoc AST in later phases)
124    Markdown(String),
125    // Future: PandocAst(Box<Inlines>)
126}
127
128impl MessageContent {
129    /// Get the raw string content for display
130    pub fn as_str(&self) -> &str {
131        match self {
132            MessageContent::Plain(s) => s,
133            MessageContent::Markdown(s) => s,
134        }
135    }
136
137    /// Convert to JSON value with type information
138    pub fn to_json(&self) -> serde_json::Value {
139        use serde_json::json;
140        match self {
141            MessageContent::Plain(s) => json!({
142                "type": "plain",
143                "content": s
144            }),
145            MessageContent::Markdown(s) => json!({
146                "type": "markdown",
147                "content": s
148            }),
149        }
150    }
151}
152
153impl From<String> for MessageContent {
154    fn from(s: String) -> Self {
155        MessageContent::Markdown(s)
156    }
157}
158
159impl From<&str> for MessageContent {
160    fn from(s: &str) -> Self {
161        MessageContent::Markdown(s.to_string())
162    }
163}
164
165/// A detail item in a diagnostic message.
166///
167/// Following tidyverse guidelines, details provide specific information about
168/// the error (what went wrong, where, with what values).
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct DetailItem {
171    /// The kind of detail (error, info, note)
172    pub kind: DetailKind,
173    /// The content of the detail
174    pub content: MessageContent,
175    /// Optional source location for this detail
176    ///
177    /// When present, this identifies where in the source code this detail applies.
178    /// This allows error messages to highlight multiple related locations.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub location: Option<quarto_source_map::SourceInfo>,
181}
182
183/// A diagnostic message following tidyverse-style structure.
184///
185/// Structure:
186/// 1. **Code**: Optional error code (e.g., "Q-1-1") for searchability
187/// 2. **Title**: Brief error message
188/// 3. **Kind**: Error, Warning, Info
189/// 4. **Problem**: What went wrong (the "must" or "can't" statement)
190/// 5. **Details**: Specific information (bulleted, max 5 per tidyverse)
191/// 6. **Hints**: Optional guidance for fixing (ends with ?)
192///
193/// # Example
194///
195/// ```ignore
196/// let msg = DiagnosticMessage {
197///     code: Some("Q-1-2".to_string()), // quarto-error-code-audit-ignore
198///     title: "Incompatible types".to_string(),
199///     kind: DiagnosticKind::Error,
200///     problem: Some("Cannot combine date and datetime types".into()),
201///     details: vec![
202///         DetailItem {
203///             kind: DetailKind::Error,
204///             content: "`x`{.arg} has type `date`{.type}".into(),
205///         },
206///         DetailItem {
207///             kind: DetailKind::Error,
208///             content: "`y`{.arg} has type `datetime`{.type}".into(),
209///         },
210///     ],
211///     hints: vec!["Convert both to the same type?".into()],
212///     source_spans: vec![],
213/// };
214/// ```
215#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216pub struct DiagnosticMessage {
217    /// Optional error code (e.g., "Q-1-1")
218    ///
219    /// Error codes are optional but encouraged. They provide:
220    /// - Searchability (users can Google "Q-1-1")
221    /// - Stability (codes don't change even if message wording improves)
222    /// - Documentation (each code maps to a detailed explanation)
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub code: Option<String>,
225
226    /// Brief title for the error
227    pub title: String,
228
229    /// The kind of diagnostic (Error, Warning, Info)
230    pub kind: DiagnosticKind,
231
232    /// The problem statement (the "what" - using "must" or "can't")
233    pub problem: Option<MessageContent>,
234
235    /// Specific error details (the "where/why" - max 5 per tidyverse)
236    pub details: Vec<DetailItem>,
237
238    /// Optional hints for fixing (ends with ?)
239    pub hints: Vec<MessageContent>,
240
241    /// Source location for this diagnostic
242    ///
243    /// When present, this identifies where in the source code the issue occurred.
244    /// The location may track transformation history, allowing the error to be
245    /// mapped back through multiple processing steps to the original source file.
246    #[serde(skip_serializing_if = "Option::is_none")]
247    pub location: Option<quarto_source_map::SourceInfo>,
248}
249
250impl DiagnosticMessage {
251    /// Access the diagnostic message builder API.
252    ///
253    /// This is the recommended way to create diagnostic messages, as the builder API
254    /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
255    /// error messages.
256    ///
257    /// # Example
258    ///
259    /// ```
260    /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
261    ///
262    /// let error = DiagnosticMessageBuilder::error("Incompatible types")
263    ///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
264    ///     .problem("Cannot combine date and datetime types")
265    ///     .add_detail("`x` has type `date`")
266    ///     .add_detail("`y` has type `datetime`")
267    ///     .add_hint("Convert both to the same type?")
268    ///     .build();
269    /// ```
270    pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
271        // This is just a convenience for accessing the builder type
272        // Users should call DiagnosticMessageBuilder::error() etc directly
273        crate::builder::DiagnosticMessageBuilder::error("")
274    }
275
276    /// Create a new diagnostic message with just a title and kind.
277    ///
278    /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
279    pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
280        Self {
281            code: None,
282            title: title.into(),
283            kind,
284            problem: None,
285            details: Vec::new(),
286            hints: Vec::new(),
287            location: None,
288        }
289    }
290
291    /// Create an error diagnostic.
292    ///
293    /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
294    pub fn error(title: impl Into<String>) -> Self {
295        Self::new(DiagnosticKind::Error, title)
296    }
297
298    /// Create a warning diagnostic.
299    ///
300    /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
301    pub fn warning(title: impl Into<String>) -> Self {
302        Self::new(DiagnosticKind::Warning, title)
303    }
304
305    /// Create an info diagnostic.
306    ///
307    /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
308    pub fn info(title: impl Into<String>) -> Self {
309        Self::new(DiagnosticKind::Info, title)
310    }
311
312    /// Set the error code.
313    ///
314    /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1").
315    ///
316    /// # Example
317    ///
318    /// ```
319    /// use quarto_error_reporting::DiagnosticMessage;
320    ///
321    /// let msg = DiagnosticMessage::error("YAML Syntax Error")
322    ///     .with_code("Q-1-1");
323    /// ```
324    pub fn with_code(mut self, code: impl Into<String>) -> Self {
325        self.code = Some(code.into());
326        self
327    }
328
329    /// Get the documentation URL for this error, if it has an error code.
330    ///
331    /// # Example
332    ///
333    /// Resolves the code against the installed [`CatalogProvider`]
334    /// (`crate::catalog`); returns `None` when no catalog is installed, the
335    /// code is unknown, or the entry has no docs URL.
336    ///
337    /// ```
338    /// use quarto_error_reporting::DiagnosticMessage;
339    ///
340    /// let msg = DiagnosticMessage::error("Internal Error")
341    ///     .with_code("Q-0-1");
342    ///
343    /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
344    /// let _ = msg.docs_url();
345    /// ```
346    pub fn docs_url(&self) -> Option<&str> {
347        self.code
348            .as_ref()
349            .and_then(|code| crate::catalog::get_docs_url(code))
350    }
351
352    /// Render this diagnostic message as text following tidyverse style.
353    ///
354    /// This is a convenience method that uses default rendering options.
355    /// For more control over rendering, use [`Self::to_text_with_options`].
356    ///
357    /// # Example
358    ///
359    /// ```
360    /// use quarto_error_reporting::DiagnosticMessageBuilder;
361    ///
362    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
363    ///     .problem("Values must be numeric")
364    ///     .add_detail("Found text in column 3")
365    ///     .add_hint("Convert to numbers first?")
366    ///     .build();
367    /// let text = msg.to_text(None);
368    /// assert!(text.contains("Error: Invalid input"));
369    /// assert!(text.contains("Values must be numeric"));
370    /// ```
371    pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
372        self.to_text_with_options(ctx, &TextRenderOptions::default())
373    }
374
375    /// Render this diagnostic message as text following tidyverse style with custom options.
376    ///
377    /// Format:
378    /// ```text
379    /// Error: title
380    /// Problem statement here
381    /// ✖ Error detail 1
382    /// ✖ Error detail 2
383    /// ℹ Info detail
384    /// • Note detail
385    /// ? Hint 1
386    /// ? Hint 2
387    /// ```
388    ///
389    /// # Example
390    ///
391    /// ```
392    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
393    ///
394    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
395    ///     .problem("Values must be numeric")
396    ///     .add_detail("Found text in column 3")
397    ///     .add_hint("Convert to numbers first?")
398    ///     .build();
399    ///
400    /// // Disable hyperlinks for snapshot testing
401    /// let options = TextRenderOptions { enable_hyperlinks: false };
402    /// let text = msg.to_text_with_options(None, &options);
403    /// assert!(text.contains("Error: Invalid input"));
404    /// ```
405    pub fn to_text_with_options(
406        &self,
407        ctx: Option<&quarto_source_map::SourceContext>,
408        options: &TextRenderOptions,
409    ) -> String {
410        self.to_text_with_renderer(ctx, options, None)
411    }
412
413    /// Like [`Self::to_text_with_options`], but explicitly selects which
414    /// source-context snippet renderer draws the visual code excerpt.
415    ///
416    /// Pass `Some(SourceRenderer::Ariadne)` or
417    /// `Some(SourceRenderer::AnnotateSnippets)` to force a specific
418    /// renderer (the corresponding feature must be enabled), or `None`
419    /// to use [`SourceRenderer::default_for_features`]. This is the seam
420    /// for experimenting with diagnostic rendering styles without
421    /// changing the rest of the API: only the source-excerpt block
422    /// differs between renderers; the surrounding structured text
423    /// (unlocated details, hints) is identical.
424    ///
425    /// When no renderer feature is enabled — or the diagnostic has no
426    /// location / source context — this falls back to the structured
427    /// tidyverse-style text block, exactly as [`Self::to_text_with_options`].
428    ///
429    /// # Example
430    ///
431    /// ```
432    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
433    ///
434    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
435    ///     .problem("Values must be numeric")
436    ///     .build();
437    ///
438    /// // `None` picks the default renderer for the enabled features.
439    /// let text = msg.to_text_with_renderer(None, &TextRenderOptions::default(), None);
440    /// assert!(text.contains("Invalid input"));
441    /// ```
442    pub fn to_text_with_renderer(
443        &self,
444        ctx: Option<&quarto_source_map::SourceContext>,
445        options: &TextRenderOptions,
446        renderer: Option<SourceRenderer>,
447    ) -> String {
448        use std::fmt::Write;
449
450        let mut result = String::new();
451
452        // Check if we have any location info that could be displayed in a
453        // source excerpt. This includes the main diagnostic location OR
454        // any detail with a location.
455        let has_any_location =
456            self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
457
458        // If we have location info and source context, render the source
459        // excerpt with the selected (or default) renderer.
460        let has_source_render = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
461            // Use main location if available, otherwise use first detail location
462            let location = self
463                .location
464                .as_ref()
465                .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
466
467            if let Some(loc) = location {
468                if let Some(snippet_output) =
469                    self.render_source_context(loc, ctx_val, options.enable_hyperlinks, renderer)
470                {
471                    result.push_str(&snippet_output);
472                    true
473                } else {
474                    false
475                }
476            } else {
477                false
478            }
479        } else {
480            false
481        };
482
483        // If we don't have a source excerpt, show full tidyverse-style content.
484        // If we do, only show details without locations and hints
485        // (the renderer already shows: title, code, problem, and located details)
486        if !has_source_render {
487            // No source excerpt - show everything in tidyverse style
488
489            // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
490            let kind_str = match self.kind {
491                DiagnosticKind::Error => "Error",
492                DiagnosticKind::Warning => "Warning",
493                DiagnosticKind::Info => "Info",
494                DiagnosticKind::Note => "Note",
495            };
496            if let Some(code) = &self.code {
497                writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
498            } else {
499                writeln!(result, "{}: {}", kind_str, self.title).unwrap();
500            }
501
502            // Show location info if available (but no ariadne rendering)
503            if let Some(loc) = &self.location {
504                // Try to map with context if available
505                if let Some(ctx) = ctx {
506                    if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
507                        && let Some(file) = ctx.get_file(mapped.file_id)
508                    {
509                        writeln!(
510                            result,
511                            "  at {}:{}:{}",
512                            file.path,
513                            mapped.location.row + 1,
514                            mapped.location.column + 1
515                        )
516                        .unwrap();
517                    }
518                } else {
519                    // No context: show immediate location (1-indexed for display)
520                    // Note: Without context, we can't get row/column from offsets
521                    // We could map_offset with ctx to get Location, but ctx is None here
522                    writeln!(result, "  at offset {}", loc.start_offset()).unwrap();
523                }
524            }
525
526            // Problem statement (optional additional context)
527            if let Some(problem) = &self.problem {
528                writeln!(result, "{}", problem.as_str()).unwrap();
529            }
530
531            // All details with appropriate bullets
532            for detail in &self.details {
533                let bullet = match detail.kind {
534                    DetailKind::Error => "✖",
535                    DetailKind::Info => "ℹ",
536                    DetailKind::Note | DetailKind::Faded => "•",
537                };
538                writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
539            }
540
541            // All hints
542            for hint in &self.hints {
543                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
544            }
545        } else {
546            // Have a source excerpt - only show details without locations and hints
547            // (the renderer shows title, code, problem, and located details)
548
549            // Details without locations (the source excerpt can't show these)
550            for detail in &self.details {
551                if detail.location.is_none() {
552                    let bullet = match detail.kind {
553                        DetailKind::Error => "✖",
554                        DetailKind::Info => "ℹ",
555                        DetailKind::Note | DetailKind::Faded => "•",
556                    };
557                    writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
558                }
559            }
560
561            // All hints (ariadne doesn't show hints)
562            for hint in &self.hints {
563                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
564            }
565        }
566
567        result
568    }
569
570    /// Render this diagnostic message as a JSON value.
571    ///
572    /// Returns a structured JSON object with all fields:
573    /// ```json
574    /// {
575    ///   "kind": "error",
576    ///   "title": "Invalid input",
577    ///   "code": "Q-1-2", // quarto-error-code-audit-ignore
578    ///   "problem": "Values must be numeric",
579    ///   "details": [{"kind": "error", "content": "Found text in column 3"}],
580    ///   "hints": ["Convert to numbers first?"]
581    /// }
582    /// ```
583    ///
584    /// # Example
585    ///
586    /// ```
587    /// use quarto_error_reporting::DiagnosticMessage;
588    ///
589    /// let msg = DiagnosticMessage::error("Something went wrong");
590    /// let json = msg.to_json();
591    /// assert_eq!(json["kind"], "error");
592    /// assert_eq!(json["title"], "Something went wrong");
593    /// ```
594    pub fn to_json(&self) -> serde_json::Value {
595        use serde_json::json;
596
597        let kind_str = match self.kind {
598            DiagnosticKind::Error => "error",
599            DiagnosticKind::Warning => "warning",
600            DiagnosticKind::Info => "info",
601            DiagnosticKind::Note => "note",
602        };
603
604        let mut obj = json!({
605            "kind": kind_str,
606            "title": self.title,
607        });
608
609        // Add optional fields
610        if let Some(code) = &self.code {
611            obj["code"] = json!(code);
612        }
613
614        if let Some(problem) = &self.problem {
615            obj["problem"] = problem.to_json();
616        }
617
618        if !self.details.is_empty() {
619            let details: Vec<_> = self
620                .details
621                .iter()
622                .map(|d| {
623                    let detail_kind = match d.kind {
624                        DetailKind::Error => "error",
625                        DetailKind::Info => "info",
626                        DetailKind::Note => "note",
627                        DetailKind::Faded => "faded",
628                    };
629                    let mut detail_obj = json!({
630                        "kind": detail_kind,
631                        "content": d.content.to_json()
632                    });
633                    if let Some(location) = &d.location {
634                        detail_obj["location"] = json!(location);
635                    }
636                    detail_obj
637                })
638                .collect();
639            obj["details"] = json!(details);
640        }
641
642        if !self.hints.is_empty() {
643            let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
644            obj["hints"] = json!(hints);
645        }
646
647        if let Some(location) = &self.location {
648            obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
649        }
650
651        obj
652    }
653
654    /// Dispatch to the selected source-context renderer.
655    ///
656    /// `renderer` of `None` resolves to [`SourceRenderer::default_for_features`].
657    /// Returns `None` when no renderer is available (no renderer feature
658    /// enabled) or the chosen renderer could not draw the excerpt (e.g.
659    /// the file content is unavailable — common in WASM), in which case
660    /// the caller falls back to the structured text block.
661    #[cfg_attr(
662        not(any(feature = "ariadne", feature = "annotate-snippets")),
663        allow(unused_variables)
664    )]
665    fn render_source_context(
666        &self,
667        main_location: &quarto_source_map::SourceInfo,
668        ctx: &quarto_source_map::SourceContext,
669        enable_hyperlinks: bool,
670        renderer: Option<SourceRenderer>,
671    ) -> Option<String> {
672        let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
673        match renderer {
674            #[cfg(feature = "ariadne")]
675            SourceRenderer::Ariadne => {
676                self.render_ariadne_source_context(main_location, ctx, enable_hyperlinks)
677            }
678            #[cfg(feature = "annotate-snippets")]
679            SourceRenderer::AnnotateSnippets => {
680                self.render_annotate_snippets_source_context(main_location, ctx, enable_hyperlinks)
681            }
682        }
683    }
684
685    /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
686    ///
687    /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
688    /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
689    ///
690    /// Only adds hyperlinks if:
691    /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
692    /// - The file exists on disk (not an ephemeral in-memory file)
693    /// - The path can be converted to an absolute path
694    ///
695    /// The `url` crate handles:
696    /// - Platform differences (Windows drive letters vs Unix paths)
697    /// - Percent-encoding of special characters
698    /// - Proper file:// URL construction
699    ///
700    /// Line and column numbers are added to the URL as a fragment identifier
701    /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
702    /// and other terminal emulators for opening files at specific positions.
703    ///
704    /// Returns the wrapped path if conditions are met, otherwise returns the original path.
705    ///
706    /// Only used by the ariadne renderer (annotate-snippets has no OSC 8 support).
707    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
708    fn wrap_path_with_hyperlink(
709        path: &str,
710        has_disk_file: bool,
711        line: Option<usize>,
712        column: Option<usize>,
713        enable_hyperlinks: bool,
714    ) -> String {
715        // Don't add hyperlinks if disabled (e.g., for snapshot testing)
716        if !enable_hyperlinks {
717            return path.to_string();
718        }
719
720        // Only add hyperlinks for real files on disk (not ephemeral in-memory files)
721        if !has_disk_file {
722            return path.to_string();
723        }
724
725        // Canonicalize to absolute path
726        let abs_path = match std::fs::canonicalize(path) {
727            Ok(p) => p,
728            Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
729        };
730
731        // Convert to file:// URL (handles Windows/Unix + percent-encoding)
732        let mut file_url = match url::Url::from_file_path(&abs_path) {
733            Ok(url) => url.as_str().to_string(),
734            Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
735        };
736
737        // Add line and column as fragment identifier (e.g., #line:column)
738        // This format is supported by iTerm2 3.4+ semantic history
739        if let Some(line_num) = line {
740            if let Some(col_num) = column {
741                file_url.push_str(&format!("#{}:{}", line_num, col_num));
742            } else {
743                file_url.push_str(&format!("#{}", line_num));
744            }
745        }
746
747        // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
748        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
749    }
750
751    /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
752    /// Just return the path unmodified.
753    #[cfg(all(feature = "ariadne", target_family = "wasm"))]
754    fn wrap_path_with_hyperlink(
755        path: &str,
756        _has_disk_file: bool,
757        _line: Option<usize>,
758        _column: Option<usize>,
759        _enable_hyperlinks: bool,
760    ) -> String {
761        path.to_string()
762    }
763
764    /// Render source context using ariadne (private helper for to_text).
765    ///
766    /// This produces the visual source code snippet with highlighting.
767    /// The tidyverse-style problem/details/hints are added separately by to_text().
768    #[cfg(feature = "ariadne")]
769    fn render_ariadne_source_context(
770        &self,
771        main_location: &quarto_source_map::SourceInfo,
772        ctx: &quarto_source_map::SourceContext,
773        enable_hyperlinks: bool,
774    ) -> Option<String> {
775        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
776
777        // Mirror of ariadne's private `Config::unimportant_color()` from
778        // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
779        // labels so they blend visually with characters that fall outside any
780        // label. Bump this constant if the ariadne dependency upgrades and
781        // changes the colour.
782        const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
783
784        // Extract file_id from the source mapping by traversing the chain
785        let file_id = main_location.root_file_id()?;
786
787        let file = ctx.get_file(file_id)?;
788
789        // Get file content: use stored content for ephemeral files, or read from disk.
790        // In WASM (and any host with no real filesystem) the disk read fails with
791        // "operation not supported on this platform"; the only graceful response is
792        // to drop the source-context snippet. The diagnostic's code, message, and
793        // hints still surface — only the Ariadne visual is unavailable.
794        let content = match &file.content {
795            Some(c) => c.clone(),
796            None => match std::fs::read_to_string(&file.path) {
797                Ok(s) => s,
798                Err(_) => return None,
799            },
800        };
801
802        // Map the location offsets back to original file positions
803        // map_offset expects relative offsets (0 = start of this SourceInfo's range)
804        let start_mapped = main_location.map_offset(0, ctx)?;
805        // For end offset, try the full length first. If that fails (e.g., when the span
806        // extends past EOF), clamp to the last valid position. This handles edge cases
807        // like errors pointing to EOF or diagnostics with off-by-one end offsets.
808        let end_mapped = main_location
809            .map_offset(main_location.length(), ctx)
810            .or_else(|| {
811                // Clamp: if length() fails, try length()-1, which should be the last valid byte
812                if main_location.length() > 0 {
813                    main_location.map_offset(main_location.length() - 1, ctx)
814                } else {
815                    None
816                }
817            })
818            .unwrap_or_else(|| start_mapped.clone());
819
820        // Create display path with OSC 8 hyperlink for clickable file paths
821        // Check if this path refers to a real file on disk (vs an ephemeral in-memory file)
822        let is_disk_file = std::path::Path::new(&file.path).exists();
823        // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
824        let line = Some(start_mapped.location.row + 1);
825        let column = Some(start_mapped.location.column + 1);
826        let display_path = Self::wrap_path_with_hyperlink(
827            &file.path,
828            is_disk_file,
829            line,
830            column,
831            enable_hyperlinks,
832        );
833
834        // Determine report kind and color
835        let (report_kind, main_color) = match self.kind {
836            DiagnosticKind::Error => (ReportKind::Error, Color::Red),
837            DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
838            DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
839            DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
840        };
841
842        // Build the report using the mapped offset for proper line:column display
843        // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
844        let mut report = Report::build(
845            report_kind,
846            (
847                display_path.clone(),
848                start_mapped.location.offset..start_mapped.location.offset,
849            ),
850        )
851        .with_config(Config::default().with_index_type(IndexType::Byte));
852
853        // Add title with error code
854        if let Some(code) = &self.code {
855            report = report.with_message(format!("[{}] {}", code, self.title));
856        } else {
857            report = report.with_message(&self.title);
858        }
859
860        // Add main location label using mapped offsets
861        let main_span = start_mapped.location.offset..end_mapped.location.offset;
862        let main_message = if let Some(problem) = &self.problem {
863            problem.as_str()
864        } else {
865            &self.title
866        };
867
868        // Set `with_order` on every label using its end offset. Ariadne
869        // groups labels by source and starts a new group whenever a label's
870        // end line is *before* the previous label's end line. Without an
871        // explicit order, multi-line main labels and per-line "padding"
872        // detail labels (used to defeat Ariadne's middle-line elision) end
873        // up in separate groups, producing a duplicated snippet block.
874        // Sorting by end offset puts the smaller-line labels first so the
875        // grouping algorithm extends rather than splits.
876        report = report.with_label(
877            Label::new((display_path.clone(), main_span.clone()))
878                .with_message(main_message)
879                .with_color(main_color)
880                .with_order(main_span.end as i32),
881        );
882
883        // Add detail locations as additional labels (only those with locations)
884        for detail in &self.details {
885            if let Some(detail_loc) = &detail.location {
886                // Extract file_id from detail location
887                let detail_file_id = match detail_loc.root_file_id() {
888                    Some(fid) => fid,
889                    None => continue, // Skip if we can't extract file_id
890                };
891
892                if detail_file_id == file_id {
893                    // Map detail offsets to original file positions
894                    // map_offset expects relative offsets (0 = start of SourceInfo's range)
895                    if let (Some(detail_start), Some(detail_end)) = (
896                        detail_loc.map_offset(0, ctx),
897                        detail_loc.map_offset(detail_loc.length(), ctx),
898                    ) {
899                        let detail_span = detail_start.location.offset..detail_end.location.offset;
900                        let detail_color = match detail.kind {
901                            DetailKind::Error => Color::Red,
902                            DetailKind::Info => Color::Cyan,
903                            DetailKind::Note => Color::Blue,
904                            // Match Ariadne's unimportant colour so faded
905                            // labels visually disappear into the surrounding
906                            // unlabelled text.
907                            DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
908                        };
909
910                        // Empty-content details exist purely to force Ariadne
911                        // to display a line that would otherwise be elided
912                        // inside a multi-line span. Leaving the label's
913                        // message at None makes Ariadne skip drawing the
914                        // `╰── ...` arrow row underneath, so the source line
915                        // appears clean.
916                        let mut label = Label::new((display_path.clone(), detail_span.clone()))
917                            .with_color(detail_color)
918                            .with_order(detail_span.end as i32);
919                        if !detail.content.as_str().is_empty() {
920                            label = label.with_message(detail.content.as_str());
921                        }
922                        report = report.with_label(label);
923                    }
924                }
925            }
926        }
927
928        // Render to string
929        let report = report.finish();
930        let mut output = Vec::new();
931        report
932            .write(
933                (display_path.clone(), Source::from(content.as_str())),
934                &mut output,
935            )
936            .ok()?;
937
938        let output_str = String::from_utf8(output).ok()?;
939
940        // Post-process to extend hyperlinks to include line:column numbers
941        // Ariadne adds :line:column after our hyperlinked path, so we need to
942        // move the hyperlink end marker to include those numbers
943        if is_disk_file && enable_hyperlinks {
944            Some(Self::extend_hyperlink_to_include_line_column(
945                &output_str,
946                &file.path,
947            ))
948        } else {
949            Some(output_str)
950        }
951    }
952
953    /// Render source context using [`annotate-snippets`](https://crates.io/crates/annotate-snippets),
954    /// the rust-lang toolchain's diagnostic style (private helper for to_text).
955    ///
956    /// Mirrors [`Self::render_ariadne_source_context`]'s offset-mapping
957    /// logic but emits the `error[CODE]: …` / `-->` / gutter-bar look.
958    /// Differences from the ariadne path, by design:
959    ///
960    /// - The error code is rendered natively via `Title::id` (e.g.
961    ///   `error[Q-2-5]: …`) rather than prefixed into the message.
962    /// - There are **no terminal hyperlinks** — annotate-snippets has no
963    ///   OSC 8 support, so `_enable_hyperlinks` is ignored.
964    /// - Detail labels are all rendered as `Context` annotations
965    ///   (annotate-snippets has no per-label color), so the `DetailKind`
966    ///   color distinction and the `Faded` blend are not reproduced.
967    /// - Empty-content "padding" details (an ariadne workaround for
968    ///   mid-span line elision) are skipped: annotate-snippets folds
969    ///   unannotated lines natively, which is the look we want here.
970    #[cfg(feature = "annotate-snippets")]
971    fn render_annotate_snippets_source_context(
972        &self,
973        main_location: &quarto_source_map::SourceInfo,
974        ctx: &quarto_source_map::SourceContext,
975        _enable_hyperlinks: bool,
976    ) -> Option<String> {
977        use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
978
979        // Resolve the root file and its content (same as the ariadne path).
980        let file_id = main_location.root_file_id()?;
981        let file = ctx.get_file(file_id)?;
982        let content = match &file.content {
983            Some(c) => c.clone(),
984            None => std::fs::read_to_string(&file.path).ok()?,
985        };
986        let content_len = content.len();
987
988        // Clamp a mapped byte range into the source, keeping start <= end.
989        let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
990            let s = start.min(content_len);
991            let e = end.min(content_len).max(s);
992            s..e
993        };
994
995        // Map the main location's offsets back to original-file byte
996        // positions, clamping the end past EOF like the ariadne path.
997        let start_mapped = main_location.map_offset(0, ctx)?;
998        let end_mapped = main_location
999            .map_offset(main_location.length(), ctx)
1000            .or_else(|| {
1001                if main_location.length() > 0 {
1002                    main_location.map_offset(main_location.length() - 1, ctx)
1003                } else {
1004                    None
1005                }
1006            })
1007            .unwrap_or_else(|| start_mapped.clone());
1008        let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
1009
1010        let level = match self.kind {
1011            DiagnosticKind::Error => Level::ERROR,
1012            DiagnosticKind::Warning => Level::WARNING,
1013            DiagnosticKind::Info => Level::INFO,
1014            DiagnosticKind::Note => Level::NOTE,
1015        };
1016
1017        // Primary label message: the problem statement, else the title.
1018        let main_message = match &self.problem {
1019            Some(problem) => problem.as_str(),
1020            None => self.title.as_str(),
1021        };
1022
1023        let mut snippet = Snippet::source(content.as_str())
1024            .path(file.path.as_str())
1025            .line_start(1)
1026            .annotation(AnnotationKind::Primary.span(main_span).label(main_message));
1027
1028        // Detail locations in the same file become Context annotations.
1029        for detail in &self.details {
1030            // Skip empty-content padding details (see the doc comment).
1031            if detail.content.as_str().is_empty() {
1032                continue;
1033            }
1034            let Some(detail_loc) = &detail.location else {
1035                continue;
1036            };
1037            if detail_loc.root_file_id() != Some(file_id) {
1038                continue;
1039            }
1040            if let (Some(detail_start), Some(detail_end)) = (
1041                detail_loc.map_offset(0, ctx),
1042                detail_loc.map_offset(detail_loc.length(), ctx),
1043            ) {
1044                let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
1045                snippet = snippet.annotation(
1046                    AnnotationKind::Context
1047                        .span(detail_span)
1048                        .label(detail.content.as_str()),
1049                );
1050            }
1051        }
1052
1053        // Build the titled group; render the error code natively via `id`.
1054        let mut title = level.primary_title(self.title.as_str());
1055        if let Some(code) = &self.code {
1056            title = title.id(code.as_str());
1057        }
1058        let group = title.element(snippet);
1059
1060        // `Renderer::render` returns text with no trailing newline, but
1061        // `to_text` appends unlocated details and hints directly after the
1062        // excerpt with `writeln!`. Match the ariadne path (which ends in a
1063        // newline) so those lines don't glue onto the last source row.
1064        let mut rendered = Renderer::styled().render(&[group]);
1065        if !rendered.ends_with('\n') {
1066            rendered.push('\n');
1067        }
1068        Some(rendered)
1069    }
1070
1071    /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
1072    ///
1073    /// Ariadne formats file references as `path:line:column`, but since we wrap the path
1074    /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
1075    /// We want: `[hyperlink:path:line:column]`
1076    ///
1077    /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
1078    /// end marker to after the line:column part.
1079    #[cfg(feature = "ariadne")]
1080    fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
1081        // Pattern: original_path followed by ]8;;\ then :numbers:numbers
1082        // We want to move the ]8;;\ to after the :numbers:numbers part
1083        let end_marker = "\x1b]8;;\x1b\\";
1084        let search_pattern = format!("{}{}", original_path, end_marker);
1085
1086        let mut result = output.to_string();
1087        while let Some(pos) = result.find(&search_pattern) {
1088            let after_marker = pos + search_pattern.len();
1089            // Check if what follows is :line:column pattern
1090            if let Some(rest) = result.get(after_marker..) {
1091                // Match :digits:digits pattern
1092                if let Some(colon_end) = Self::find_line_column_end(rest) {
1093                    // Move the end marker to after the :line:column
1094                    let before = &result[..pos + original_path.len()];
1095                    let line_col = &rest[..colon_end];
1096                    let after = &rest[colon_end..];
1097                    result = format!("{}{}{}{}", before, line_col, end_marker, after);
1098                    continue;
1099                }
1100            }
1101            break;
1102        }
1103        result
1104    }
1105
1106    /// Find the end position of a :line:column pattern at the start of the string.
1107    /// Returns None if the pattern doesn't match.
1108    #[cfg(feature = "ariadne")]
1109    fn find_line_column_end(s: &str) -> Option<usize> {
1110        let bytes = s.as_bytes();
1111        if bytes.is_empty() || bytes[0] != b':' {
1112            return None;
1113        }
1114
1115        let mut pos = 1;
1116        // Read digits for line number
1117        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1118            pos += 1;
1119        }
1120        if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
1121            return None; // No digits or no second colon
1122        }
1123
1124        pos += 1; // Skip second colon
1125        let col_start = pos;
1126        // Read digits for column number
1127        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1128            pos += 1;
1129        }
1130        if pos == col_start {
1131            return None; // No digits for column
1132        }
1133
1134        Some(pos)
1135    }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    #[test]
1143    fn test_diagnostic_kind() {
1144        assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
1145        assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
1146    }
1147
1148    #[test]
1149    fn test_message_content_from_str() {
1150        let content: MessageContent = "test".into();
1151        assert_eq!(content.as_str(), "test");
1152    }
1153
1154    #[test]
1155    fn test_diagnostic_message_new() {
1156        let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
1157        assert_eq!(msg.title, "Test error");
1158        assert_eq!(msg.kind, DiagnosticKind::Error);
1159        assert!(msg.code.is_none());
1160        assert!(msg.problem.is_none());
1161        assert!(msg.details.is_empty());
1162        assert!(msg.hints.is_empty());
1163    }
1164
1165    #[test]
1166    fn test_diagnostic_message_constructors() {
1167        let error = DiagnosticMessage::error("Error");
1168        assert_eq!(error.kind, DiagnosticKind::Error);
1169        assert!(error.code.is_none());
1170
1171        let warning = DiagnosticMessage::warning("Warning");
1172        assert_eq!(warning.kind, DiagnosticKind::Warning);
1173
1174        let info = DiagnosticMessage::info("Info");
1175        assert_eq!(info.kind, DiagnosticKind::Info);
1176    }
1177
1178    #[test]
1179    fn test_with_code() {
1180        let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
1181        assert_eq!(msg.code, Some("Q-1-1".to_string()));
1182    }
1183
1184    // The positive case — `docs_url()` for a real code resolves to the
1185    // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
1186    // where the `Q-*` catalog is installed. Here we only cover the
1187    // catalog-free cases (no code / unknown code → `None`), which hold
1188    // regardless of whether a catalog is installed.
1189
1190    #[test]
1191    fn test_docs_url_without_code() {
1192        let msg = DiagnosticMessage::error("Test error");
1193        assert!(msg.docs_url().is_none());
1194    }
1195
1196    #[test]
1197    fn test_docs_url_invalid_code() {
1198        let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
1199        assert!(msg.docs_url().is_none());
1200    }
1201
1202    #[test]
1203    fn test_to_text_simple_error() {
1204        let msg = DiagnosticMessage::error("Something went wrong");
1205        assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
1206    }
1207
1208    #[test]
1209    fn test_to_text_with_code() {
1210        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1211        assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
1212    }
1213
1214    #[test]
1215    fn test_to_text_full_message() {
1216        use crate::builder::DiagnosticMessageBuilder;
1217
1218        let msg = DiagnosticMessageBuilder::error("Invalid input")
1219            .problem("Values must be numeric")
1220            .add_detail("Found text in column 3")
1221            .add_info("Columns should contain only numbers")
1222            .add_hint("Convert to numbers first?")
1223            .build();
1224
1225        let text = msg.to_text(None);
1226        assert!(text.contains("Error: Invalid input"));
1227        assert!(text.contains("Values must be numeric"));
1228        assert!(text.contains("✖ Found text in column 3"));
1229        assert!(text.contains("ℹ Columns should contain only numbers"));
1230        assert!(text.contains("ℹ Convert to numbers first?"));
1231    }
1232
1233    #[test]
1234    fn test_to_json_simple() {
1235        let msg = DiagnosticMessage::error("Something went wrong");
1236        let json = msg.to_json();
1237
1238        assert_eq!(json["kind"], "error");
1239        assert_eq!(json["title"], "Something went wrong");
1240        assert!(json.get("code").is_none());
1241        assert!(json.get("problem").is_none());
1242    }
1243
1244    #[test]
1245    fn test_to_json_with_code() {
1246        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1247        let json = msg.to_json();
1248
1249        assert_eq!(json["kind"], "error");
1250        assert_eq!(json["title"], "Something went wrong");
1251        assert_eq!(json["code"], "Q-1-1");
1252    }
1253
1254    #[test]
1255    fn test_to_json_full_message() {
1256        use crate::builder::DiagnosticMessageBuilder;
1257
1258        let msg = DiagnosticMessageBuilder::error("Invalid input")
1259            .with_code("Q-1-2") // quarto-error-code-audit-ignore
1260            .problem("Values must be numeric")
1261            .add_detail("Found text in column 3")
1262            .add_info("Expected numbers")
1263            .add_hint("Convert to numbers first?")
1264            .build();
1265
1266        let json = msg.to_json();
1267        assert_eq!(json["kind"], "error");
1268        assert_eq!(json["title"], "Invalid input");
1269        assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1270        assert_eq!(json["problem"]["type"], "markdown");
1271        assert_eq!(json["problem"]["content"], "Values must be numeric");
1272        assert_eq!(json["details"][0]["kind"], "error");
1273        assert_eq!(json["details"][0]["content"]["type"], "markdown");
1274        assert_eq!(
1275            json["details"][0]["content"]["content"],
1276            "Found text in column 3"
1277        );
1278        assert_eq!(json["details"][1]["kind"], "info");
1279        assert_eq!(json["details"][1]["content"]["type"], "markdown");
1280        assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1281        assert_eq!(json["hints"][0]["type"], "markdown");
1282        assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1283    }
1284
1285    #[test]
1286    fn test_to_json_warning() {
1287        let msg = DiagnosticMessage::warning("Be careful");
1288        let json = msg.to_json();
1289
1290        assert_eq!(json["kind"], "warning");
1291        assert_eq!(json["title"], "Be careful");
1292    }
1293
1294    #[test]
1295    fn test_location_in_to_text_without_context() {
1296        use crate::builder::DiagnosticMessageBuilder;
1297
1298        // Create a location at offsets 100-110
1299        let location =
1300            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1301
1302        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1303            .with_location(location)
1304            .build();
1305
1306        let text = msg.to_text(None);
1307
1308        // Without context, should show offset (we can't get row/column without context)
1309        assert!(text.contains("Invalid syntax"));
1310        assert!(text.contains("at offset 100"));
1311    }
1312
1313    #[test]
1314    fn test_location_in_to_text_with_context() {
1315        use crate::builder::DiagnosticMessageBuilder;
1316
1317        // Create a source context with a file
1318        let mut ctx = quarto_source_map::SourceContext::new();
1319        let file_id = ctx.add_file(
1320            "test.qmd".to_string(),
1321            Some("line 1\nline 2\nline 3\nline 4".to_string()),
1322        );
1323
1324        // Create a location in that file (offset 7 is start of "line 2")
1325        let location = quarto_source_map::SourceInfo::original(
1326            file_id, 7,  // Start of "line 2"
1327            13, // End of "line 2"
1328        );
1329
1330        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1331            .with_location(location)
1332            .build();
1333
1334        let text = msg.to_text(Some(&ctx));
1335
1336        // With context, should show file path and 1-indexed location
1337        assert!(text.contains("Invalid syntax"));
1338        assert!(text.contains("test.qmd"));
1339        assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1340    }
1341
1342    #[test]
1343    fn test_location_in_to_json() {
1344        use crate::builder::DiagnosticMessageBuilder;
1345
1346        let location =
1347            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1348
1349        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1350            .with_location(location)
1351            .build();
1352
1353        let json = msg.to_json();
1354
1355        // Should have location field with Original variant
1356        assert!(json.get("location").is_some());
1357        let loc = &json["location"];
1358
1359        // Verify the SourceInfo is serialized correctly (as Original enum variant)
1360        assert!(loc.get("Original").is_some());
1361        let original = &loc["Original"];
1362        assert_eq!(original["file_id"], 0);
1363        assert_eq!(original["start_offset"], 100);
1364        assert_eq!(original["end_offset"], 110);
1365    }
1366
1367    #[test]
1368    fn test_location_optional_in_to_json() {
1369        let msg = DiagnosticMessage::error("No location");
1370        let json = msg.to_json();
1371
1372        // Should not have location field when not provided
1373        assert!(json.get("location").is_none());
1374    }
1375
1376    #[test]
1377    fn test_text_render_options_disable_hyperlinks() {
1378        use crate::builder::DiagnosticMessageBuilder;
1379
1380        let mut ctx = quarto_source_map::SourceContext::new();
1381        let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1382
1383        let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1384
1385        let msg = DiagnosticMessageBuilder::error("Test error")
1386            .with_location(location)
1387            .build();
1388
1389        // With hyperlinks enabled (default)
1390        let with_hyperlinks = msg.to_text(Some(&ctx));
1391
1392        // With hyperlinks disabled
1393        let options = TextRenderOptions {
1394            enable_hyperlinks: false,
1395        };
1396        let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1397
1398        // When hyperlinks are disabled, output should be different
1399        // (specifically, no OSC 8 escape sequences)
1400        if with_hyperlinks.contains("\x1b]8;") {
1401            assert!(
1402                !without_hyperlinks.contains("\x1b]8;"),
1403                "Disabled hyperlinks should not contain OSC 8 codes"
1404            );
1405        }
1406    }
1407
1408    #[test]
1409    fn test_text_render_options_default() {
1410        let options = TextRenderOptions::default();
1411        assert!(
1412            options.enable_hyperlinks,
1413            "Default should enable hyperlinks"
1414        );
1415    }
1416
1417    #[test]
1418    fn test_render_with_custom_options() {
1419        use crate::builder::DiagnosticMessageBuilder;
1420
1421        let msg = DiagnosticMessageBuilder::error("Test")
1422            .problem("Something went wrong")
1423            .add_detail("Detail 1")
1424            .add_hint("Try this")
1425            .build();
1426
1427        let options = TextRenderOptions {
1428            enable_hyperlinks: false,
1429        };
1430
1431        let text = msg.to_text_with_options(None, &options);
1432
1433        // Should still render properly without hyperlinks
1434        assert!(text.contains("Error: Test"));
1435        assert!(text.contains("Something went wrong"));
1436        assert!(text.contains("Detail 1"));
1437        assert!(text.contains("Try this"));
1438    }
1439
1440    /// Strip CSI SGR color sequences (`ESC [ … m`). The annotate-snippets
1441    /// path emits no OSC 8 hyperlinks, so color is all we need to remove
1442    /// to make substring assertions robust to styling.
1443    #[cfg(feature = "annotate-snippets")]
1444    fn strip_ansi(s: &str) -> String {
1445        let mut out = String::new();
1446        let mut chars = s.chars().peekable();
1447        while let Some(c) = chars.next() {
1448            if c == '\u{1b}' {
1449                for n in chars.by_ref() {
1450                    if n == 'm' {
1451                        break;
1452                    }
1453                }
1454            } else {
1455                out.push(c);
1456            }
1457        }
1458        out
1459    }
1460
1461    /// The annotate-snippets renderer emits the rust-lang toolchain look:
1462    /// an `error[CODE]: …` header, a `-->` origin line, and `^` underlines
1463    /// — not ariadne's enclosing box.
1464    #[cfg(feature = "annotate-snippets")]
1465    #[test]
1466    fn annotate_snippets_renderer_produces_rust_style_output() {
1467        use crate::builder::DiagnosticMessageBuilder;
1468
1469        let mut ctx = quarto_source_map::SourceContext::new();
1470        let file_id = ctx.add_file(
1471            "test.qmd".to_string(),
1472            Some("line 1\nline 2\nline 3".to_string()),
1473        );
1474        // Offsets 7..13 cover "line 2" on row 2.
1475        let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
1476        let msg = DiagnosticMessageBuilder::error("Bad thing")
1477            .with_code("Q-9-9")
1478            .with_location(location)
1479            .problem("this is wrong")
1480            .build();
1481
1482        let opts = TextRenderOptions {
1483            enable_hyperlinks: false,
1484        };
1485        let raw =
1486            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1487        let text = strip_ansi(&raw);
1488
1489        assert!(
1490            text.contains("error[Q-9-9]"),
1491            "expected rust-style code header; got: {text:?}"
1492        );
1493        assert!(
1494            text.contains("-->"),
1495            "expected rust-style origin arrow; got: {text:?}"
1496        );
1497        assert!(
1498            text.contains("test.qmd:2:1"),
1499            "expected mapped location; got: {text:?}"
1500        );
1501        assert!(
1502            !text.contains('\u{256D}'),
1503            "annotate-snippets must not draw ariadne's box corner; got: {text:?}"
1504        );
1505        // No OSC 8 hyperlinks from annotate-snippets.
1506        assert!(
1507            !raw.contains("\u{1b}]8;"),
1508            "annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
1509        );
1510    }
1511
1512    /// Forcing a specific renderer is honored: ariadne draws its boxed
1513    /// excerpt (the U+256D corner) while annotate-snippets does not.
1514    #[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
1515    #[test]
1516    fn renderer_selection_switches_styles() {
1517        use crate::builder::DiagnosticMessageBuilder;
1518
1519        let mut ctx = quarto_source_map::SourceContext::new();
1520        let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
1521        let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); // "beta"
1522        let msg = DiagnosticMessageBuilder::error("Pick a style")
1523            .with_location(location)
1524            .build();
1525        let opts = TextRenderOptions {
1526            enable_hyperlinks: false,
1527        };
1528
1529        let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
1530        let snippets =
1531            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1532
1533        assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
1534        assert!(
1535            !strip_ansi(&snippets).contains('\u{256D}'),
1536            "annotate-snippets does not"
1537        );
1538        assert!(strip_ansi(&snippets).contains("-->"));
1539    }
1540}