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 or ANSI color in
43/// the source-context snippet.
44///
45/// The default enables everything a terminal can use (color and OSC 8
46/// hyperlinks). [`TextRenderOptions::plain`] disables both, producing text
47/// with no escape sequences of any kind — for logs, JSON transport, and
48/// other machine consumers.
49///
50/// The struct is `#[non_exhaustive]`: construct it from
51/// [`TextRenderOptions::default`] or [`TextRenderOptions::plain`] and adjust
52/// it with the builder methods, so new options can be added without
53/// breaking callers.
54///
55/// # Example
56///
57/// ```
58/// use quarto_error_reporting::TextRenderOptions;
59///
60/// // Terminal output without hyperlinks (e.g. for snapshot tests).
61/// let opts = TextRenderOptions::default().hyperlinks(false);
62/// assert!(opts.enable_color && !opts.enable_hyperlinks);
63///
64/// // No escape sequences at all.
65/// let plain = TextRenderOptions::plain();
66/// assert!(!plain.enable_color && !plain.enable_hyperlinks);
67/// ```
68#[derive(Debug, Clone)]
69#[non_exhaustive]
70pub struct TextRenderOptions {
71    /// Enable OSC 8 hyperlinks for clickable file paths in terminals.
72    ///
73    /// When enabled, file paths in error messages will include terminal
74    /// escape codes for clickable links (supported by iTerm2, VS Code, etc.).
75    /// Disable for snapshot testing to avoid absolute path differences.
76    pub enable_hyperlinks: bool,
77    /// Enable ANSI (SGR) color codes in the source-context snippet.
78    ///
79    /// When disabled, the snippet renderer draws the same layout with no
80    /// color escape sequences. Independent of
81    /// [`enable_hyperlinks`](Self::enable_hyperlinks): disable both (see
82    /// [`TextRenderOptions::plain`]) for output free of escape bytes.
83    pub enable_color: bool,
84}
85
86impl Default for TextRenderOptions {
87    fn default() -> Self {
88        Self {
89            enable_hyperlinks: true,
90            enable_color: true,
91        }
92    }
93}
94
95impl TextRenderOptions {
96    /// Options producing text with no escape sequences of any kind: no ANSI
97    /// color and no OSC 8 hyperlinks.
98    pub fn plain() -> Self {
99        Self {
100            enable_hyperlinks: false,
101            enable_color: false,
102        }
103    }
104
105    /// Set whether file paths carry OSC 8 hyperlinks
106    /// (see [`enable_hyperlinks`](Self::enable_hyperlinks)).
107    pub fn hyperlinks(mut self, enable: bool) -> Self {
108        self.enable_hyperlinks = enable;
109        self
110    }
111
112    /// Set whether the source-context snippet carries ANSI color
113    /// (see [`enable_color`](Self::enable_color)).
114    pub fn color(mut self, enable: bool) -> Self {
115        self.enable_color = enable;
116        self
117    }
118}
119
120/// Selects which source-context snippet renderer draws the visual code
121/// excerpt in [`DiagnosticMessage::to_text_with_renderer`].
122///
123/// The available variants depend on which renderer features are enabled
124/// at compile time, so this enum is `#[non_exhaustive]`: with neither
125/// `ariadne` nor `annotate-snippets` enabled it has no variants at all,
126/// and downstream `match`es must include a wildcard arm to stay
127/// compiling across feature combinations.
128///
129/// Pass `None` to [`DiagnosticMessage::to_text_with_renderer`] (or use
130/// [`DiagnosticMessage::to_text`] / [`DiagnosticMessage::to_text_with_options`])
131/// to let the crate pick a default via [`SourceRenderer::default_for_features`].
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
133#[non_exhaustive]
134pub enum SourceRenderer {
135    /// [ariadne](https://crates.io/crates/ariadne)-style rendering: a
136    /// boxed source excerpt. Available with the `ariadne` feature (on by
137    /// default).
138    #[cfg(feature = "ariadne")]
139    Ariadne,
140    /// [annotate-snippets](https://crates.io/crates/annotate-snippets)-style
141    /// rendering: the rust-lang toolchain's `-->` / gutter-bar look.
142    /// Available with the `annotate-snippets` feature.
143    #[cfg(feature = "annotate-snippets")]
144    AnnotateSnippets,
145}
146
147impl SourceRenderer {
148    /// The renderer used when the caller does not specify one.
149    ///
150    /// Prefers [`SourceRenderer::Ariadne`] when the `ariadne` feature is
151    /// enabled (preserving historical behavior), then falls back to
152    /// [`SourceRenderer::AnnotateSnippets`]. Returns `None` when no
153    /// renderer feature is enabled, in which case `to_text` drops the
154    /// source-context snippet and prints the structured text block.
155    pub fn default_for_features() -> Option<Self> {
156        // Exactly one of these `#[cfg]` blocks survives in any feature
157        // configuration, so the surviving block is the function's tail
158        // expression — no `return` and no unreachable code.
159        #[cfg(feature = "ariadne")]
160        {
161            Some(Self::Ariadne)
162        }
163        #[cfg(all(not(feature = "ariadne"), feature = "annotate-snippets"))]
164        {
165            Some(Self::AnnotateSnippets)
166        }
167        #[cfg(all(not(feature = "ariadne"), not(feature = "annotate-snippets")))]
168        {
169            None
170        }
171    }
172}
173
174/// The content of a message or detail item.
175///
176/// This will eventually support Pandoc AST for rich formatting, but starts
177/// with simpler string-based content.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub enum MessageContent {
180    /// Plain text content
181    Plain(String),
182    /// Markdown content (will be parsed to Pandoc AST in later phases)
183    Markdown(String),
184    // Future: PandocAst(Box<Inlines>)
185}
186
187impl MessageContent {
188    /// Get the raw string content for display
189    pub fn as_str(&self) -> &str {
190        match self {
191            MessageContent::Plain(s) => s,
192            MessageContent::Markdown(s) => s,
193        }
194    }
195
196    /// Convert to JSON value with type information
197    pub fn to_json(&self) -> serde_json::Value {
198        use serde_json::json;
199        match self {
200            MessageContent::Plain(s) => json!({
201                "type": "plain",
202                "content": s
203            }),
204            MessageContent::Markdown(s) => json!({
205                "type": "markdown",
206                "content": s
207            }),
208        }
209    }
210}
211
212impl From<String> for MessageContent {
213    fn from(s: String) -> Self {
214        MessageContent::Markdown(s)
215    }
216}
217
218impl From<&str> for MessageContent {
219    fn from(s: &str) -> Self {
220        MessageContent::Markdown(s.to_string())
221    }
222}
223
224/// A detail item in a diagnostic message.
225///
226/// Following tidyverse guidelines, details provide specific information about
227/// the error (what went wrong, where, with what values).
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229pub struct DetailItem {
230    /// The kind of detail (error, info, note)
231    pub kind: DetailKind,
232    /// The content of the detail
233    pub content: MessageContent,
234    /// Optional source location for this detail
235    ///
236    /// When present, this identifies where in the source code this detail applies.
237    /// This allows error messages to highlight multiple related locations.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub location: Option<quarto_source_map::SourceInfo>,
240}
241
242/// A diagnostic message following tidyverse-style structure.
243///
244/// Structure:
245/// 1. **Code**: Optional error code (e.g., "Q-1-1") for searchability
246/// 2. **Title**: Brief error message
247/// 3. **Kind**: Error, Warning, Info
248/// 4. **Problem**: What went wrong (the "must" or "can't" statement)
249/// 5. **Details**: Specific information (bulleted, max 5 per tidyverse)
250/// 6. **Hints**: Optional guidance for fixing (ends with ?)
251///
252/// # Example
253///
254/// ```ignore
255/// let msg = DiagnosticMessage {
256///     code: Some("Q-1-2".to_string()), // quarto-error-code-audit-ignore
257///     title: "Incompatible types".to_string(),
258///     kind: DiagnosticKind::Error,
259///     problem: Some("Cannot combine date and datetime types".into()),
260///     details: vec![
261///         DetailItem {
262///             kind: DetailKind::Error,
263///             content: "`x`{.arg} has type `date`{.type}".into(),
264///         },
265///         DetailItem {
266///             kind: DetailKind::Error,
267///             content: "`y`{.arg} has type `datetime`{.type}".into(),
268///         },
269///     ],
270///     hints: vec!["Convert both to the same type?".into()],
271///     source_spans: vec![],
272/// };
273/// ```
274#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
275pub struct DiagnosticMessage {
276    /// Optional error code (e.g., "Q-1-1")
277    ///
278    /// Error codes are optional but encouraged. They provide:
279    /// - Searchability (users can Google "Q-1-1")
280    /// - Stability (codes don't change even if message wording improves)
281    /// - Documentation (each code maps to a detailed explanation)
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub code: Option<String>,
284
285    /// Brief title for the error
286    pub title: String,
287
288    /// The kind of diagnostic (Error, Warning, Info)
289    pub kind: DiagnosticKind,
290
291    /// The problem statement (the "what" - using "must" or "can't")
292    pub problem: Option<MessageContent>,
293
294    /// Specific error details (the "where/why" - max 5 per tidyverse)
295    pub details: Vec<DetailItem>,
296
297    /// Optional hints for fixing (ends with ?)
298    pub hints: Vec<MessageContent>,
299
300    /// Source location for this diagnostic
301    ///
302    /// When present, this identifies where in the source code the issue occurred.
303    /// The location may track transformation history, allowing the error to be
304    /// mapped back through multiple processing steps to the original source file.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub location: Option<quarto_source_map::SourceInfo>,
307}
308
309/// A minimal multi-source [`ariadne::Cache`] over in-memory files, keyed
310/// by display path. The single-file `(id, source)` tuple used previously
311/// cannot serve labels rooted in another `Concat` piece, which need one
312/// source section per file.
313#[cfg(feature = "ariadne")]
314struct ContextSourceCache {
315    files: Vec<(String, ariadne::Source<String>)>,
316}
317
318#[cfg(feature = "ariadne")]
319impl ariadne::Cache<String> for ContextSourceCache {
320    type Storage = String;
321
322    fn fetch(&mut self, id: &String) -> Result<&ariadne::Source<String>, impl std::fmt::Debug> {
323        self.files
324            .iter()
325            .find(|(path, _)| path == id)
326            .map(|(_, source)| source)
327            .ok_or(MissingSource)
328    }
329
330    fn display<'a>(&self, id: &'a String) -> Option<impl std::fmt::Display + 'a> {
331        Some(id)
332    }
333}
334
335/// Fetch-error type for [`ContextSourceCache`]; ariadne only formats it
336/// into an eprintln for sources no label references.
337#[cfg(feature = "ariadne")]
338struct MissingSource;
339
340#[cfg(feature = "ariadne")]
341impl std::fmt::Debug for MissingSource {
342    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
343        f.write_str("source not registered in the diagnostic's source context")
344    }
345}
346
347impl DiagnosticMessage {
348    /// Access the diagnostic message builder API.
349    ///
350    /// This is the recommended way to create diagnostic messages, as the builder API
351    /// encodes tidyverse-style guidelines and makes it easy to construct well-structured
352    /// error messages.
353    ///
354    /// # Example
355    ///
356    /// ```
357    /// use quarto_error_reporting::{DiagnosticMessage, DiagnosticMessageBuilder};
358    ///
359    /// let error = DiagnosticMessageBuilder::error("Incompatible types")
360    ///     .with_code("Q-1-2") // quarto-error-code-audit-ignore
361    ///     .problem("Cannot combine date and datetime types")
362    ///     .add_detail("`x` has type `date`")
363    ///     .add_detail("`y` has type `datetime`")
364    ///     .add_hint("Convert both to the same type?")
365    ///     .build();
366    /// ```
367    pub fn builder() -> crate::builder::DiagnosticMessageBuilder {
368        // This is just a convenience for accessing the builder type
369        // Users should call DiagnosticMessageBuilder::error() etc directly
370        crate::builder::DiagnosticMessageBuilder::error("")
371    }
372
373    /// Create a new diagnostic message with just a title and kind.
374    ///
375    /// Note: Consider using `DiagnosticMessage::builder()` instead for better structure.
376    pub fn new(kind: DiagnosticKind, title: impl Into<String>) -> Self {
377        Self {
378            code: None,
379            title: title.into(),
380            kind,
381            problem: None,
382            details: Vec::new(),
383            hints: Vec::new(),
384            location: None,
385        }
386    }
387
388    /// Create an error diagnostic.
389    ///
390    /// Note: Consider using `DiagnosticMessage::builder().error()` instead for better structure.
391    pub fn error(title: impl Into<String>) -> Self {
392        Self::new(DiagnosticKind::Error, title)
393    }
394
395    /// Create a warning diagnostic.
396    ///
397    /// Note: Consider using `DiagnosticMessage::builder().warning()` instead for better structure.
398    pub fn warning(title: impl Into<String>) -> Self {
399        Self::new(DiagnosticKind::Warning, title)
400    }
401
402    /// Create an info diagnostic.
403    ///
404    /// Note: Consider using `DiagnosticMessage::builder().info()` instead for better structure.
405    pub fn info(title: impl Into<String>) -> Self {
406        Self::new(DiagnosticKind::Info, title)
407    }
408
409    /// Set the error code.
410    ///
411    /// Error codes follow the format `Q-<subsystem>-<number>` (e.g., "Q-1-1").
412    ///
413    /// # Example
414    ///
415    /// ```
416    /// use quarto_error_reporting::DiagnosticMessage;
417    ///
418    /// let msg = DiagnosticMessage::error("YAML Syntax Error")
419    ///     .with_code("Q-1-1");
420    /// ```
421    pub fn with_code(mut self, code: impl Into<String>) -> Self {
422        self.code = Some(code.into());
423        self
424    }
425
426    /// Get the documentation URL for this error, if it has an error code.
427    ///
428    /// # Example
429    ///
430    /// Resolves the code against the installed [`CatalogProvider`]
431    /// (`crate::catalog`); returns `None` when no catalog is installed, the
432    /// code is unknown, or the entry has no docs URL.
433    ///
434    /// ```
435    /// use quarto_error_reporting::DiagnosticMessage;
436    ///
437    /// let msg = DiagnosticMessage::error("Internal Error")
438    ///     .with_code("Q-0-1");
439    ///
440    /// // `Some(url)` iff a catalog mapping "Q-0-1" (with a docs URL) is installed.
441    /// let _ = msg.docs_url();
442    /// ```
443    pub fn docs_url(&self) -> Option<&str> {
444        self.code
445            .as_ref()
446            .and_then(|code| crate::catalog::get_docs_url(code))
447    }
448
449    /// Render this diagnostic message as text following tidyverse style.
450    ///
451    /// This is a convenience method that uses default rendering options.
452    /// For more control over rendering, use [`Self::to_text_with_options`].
453    ///
454    /// # Example
455    ///
456    /// ```
457    /// use quarto_error_reporting::DiagnosticMessageBuilder;
458    ///
459    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
460    ///     .problem("Values must be numeric")
461    ///     .add_detail("Found text in column 3")
462    ///     .add_hint("Convert to numbers first?")
463    ///     .build();
464    /// let text = msg.to_text(None);
465    /// assert!(text.contains("Error: Invalid input"));
466    /// assert!(text.contains("Values must be numeric"));
467    /// ```
468    pub fn to_text(&self, ctx: Option<&quarto_source_map::SourceContext>) -> String {
469        self.to_text_with_options(ctx, &TextRenderOptions::default())
470    }
471
472    /// Render this diagnostic message as text following tidyverse style with custom options.
473    ///
474    /// Format:
475    /// ```text
476    /// Error: title
477    /// Problem statement here
478    /// ✖ Error detail 1
479    /// ✖ Error detail 2
480    /// ℹ Info detail
481    /// • Note detail
482    /// ? Hint 1
483    /// ? Hint 2
484    /// ```
485    ///
486    /// # Example
487    ///
488    /// ```
489    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
490    ///
491    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
492    ///     .problem("Values must be numeric")
493    ///     .add_detail("Found text in column 3")
494    ///     .add_hint("Convert to numbers first?")
495    ///     .build();
496    ///
497    /// // Disable hyperlinks for snapshot testing
498    /// let options = TextRenderOptions::default().hyperlinks(false);
499    /// let text = msg.to_text_with_options(None, &options);
500    /// assert!(text.contains("Error: Invalid input"));
501    /// ```
502    pub fn to_text_with_options(
503        &self,
504        ctx: Option<&quarto_source_map::SourceContext>,
505        options: &TextRenderOptions,
506    ) -> String {
507        self.to_text_with_renderer(ctx, options, None)
508    }
509
510    /// Like [`Self::to_text_with_options`], but explicitly selects which
511    /// source-context snippet renderer draws the visual code excerpt.
512    ///
513    /// Pass `Some(SourceRenderer::Ariadne)` or
514    /// `Some(SourceRenderer::AnnotateSnippets)` to force a specific
515    /// renderer (the corresponding feature must be enabled), or `None`
516    /// to use [`SourceRenderer::default_for_features`]. This is the seam
517    /// for experimenting with diagnostic rendering styles without
518    /// changing the rest of the API: only the source-excerpt block
519    /// differs between renderers; the surrounding structured text
520    /// (unlocated details, hints) is identical.
521    ///
522    /// When no renderer feature is enabled — or the diagnostic has no
523    /// location / source context — this falls back to the structured
524    /// tidyverse-style text block, exactly as [`Self::to_text_with_options`].
525    ///
526    /// # Example
527    ///
528    /// ```
529    /// use quarto_error_reporting::{DiagnosticMessageBuilder, TextRenderOptions};
530    ///
531    /// let msg = DiagnosticMessageBuilder::error("Invalid input")
532    ///     .problem("Values must be numeric")
533    ///     .build();
534    ///
535    /// // `None` picks the default renderer for the enabled features.
536    /// let text = msg.to_text_with_renderer(None, &TextRenderOptions::default(), None);
537    /// assert!(text.contains("Invalid input"));
538    /// ```
539    pub fn to_text_with_renderer(
540        &self,
541        ctx: Option<&quarto_source_map::SourceContext>,
542        options: &TextRenderOptions,
543        renderer: Option<SourceRenderer>,
544    ) -> String {
545        use std::fmt::Write;
546
547        let mut result = String::new();
548
549        // Check if we have any location info that could be displayed in a
550        // source excerpt. This includes the main diagnostic location OR
551        // any detail with a location.
552        let has_any_location =
553            self.location.is_some() || self.details.iter().any(|d| d.location.is_some());
554
555        // If we have location info and source context, render the source
556        // excerpt with the selected (or default) renderer.
557        let has_source_render = if let (true, Some(ctx_val)) = (has_any_location, ctx) {
558            // Use main location if available, otherwise use first detail location
559            let location = self
560                .location
561                .as_ref()
562                .or_else(|| self.details.iter().find_map(|d| d.location.as_ref()));
563
564            if let Some(loc) = location {
565                if let Some(snippet_output) =
566                    self.render_source_context(loc, ctx_val, options, renderer)
567                {
568                    result.push_str(&snippet_output);
569                    true
570                } else {
571                    false
572                }
573            } else {
574                false
575            }
576        } else {
577            false
578        };
579
580        // If we don't have a source excerpt, show full tidyverse-style content.
581        // If we do, only show details without locations and hints
582        // (the renderer already shows: title, code, problem, and located details)
583        if !has_source_render {
584            // No source excerpt - show everything in tidyverse style
585
586            // Title with kind prefix and error code (e.g., "Error [Q-1-1]: Invalid input")
587            let kind_str = match self.kind {
588                DiagnosticKind::Error => "Error",
589                DiagnosticKind::Warning => "Warning",
590                DiagnosticKind::Info => "Info",
591                DiagnosticKind::Note => "Note",
592            };
593            if let Some(code) = &self.code {
594                writeln!(result, "{} [{}]: {}", kind_str, code, self.title).unwrap();
595            } else {
596                writeln!(result, "{}: {}", kind_str, self.title).unwrap();
597            }
598
599            // Show location info if available (but no ariadne rendering)
600            if let Some(loc) = &self.location {
601                // Try to map with context if available
602                if let Some(ctx) = ctx {
603                    if let Some(mapped) = loc.map_offset(loc.start_offset(), ctx)
604                        && let Some(file) = ctx.get_file(mapped.file_id)
605                    {
606                        writeln!(
607                            result,
608                            "  at {}:{}:{}",
609                            file.path,
610                            mapped.location.row + 1,
611                            mapped.location.column + 1
612                        )
613                        .unwrap();
614                    }
615                } else {
616                    // No context: show immediate location (1-indexed for display)
617                    // Note: Without context, we can't get row/column from offsets
618                    // We could map_offset with ctx to get Location, but ctx is None here
619                    writeln!(result, "  at offset {}", loc.start_offset()).unwrap();
620                }
621            }
622
623            // Problem statement (optional additional context)
624            if let Some(problem) = &self.problem {
625                writeln!(result, "{}", problem.as_str()).unwrap();
626            }
627
628            // All details with appropriate bullets
629            for detail in &self.details {
630                let bullet = match detail.kind {
631                    DetailKind::Error => "✖",
632                    DetailKind::Info => "ℹ",
633                    DetailKind::Note | DetailKind::Faded => "•",
634                };
635                writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
636            }
637
638            // All hints
639            for hint in &self.hints {
640                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
641            }
642        } else {
643            // Have a source excerpt - only show details without locations and hints
644            // (the renderer shows title, code, problem, and located details)
645
646            // Details without locations (the source excerpt can't show these)
647            for detail in &self.details {
648                if detail.location.is_none() {
649                    let bullet = match detail.kind {
650                        DetailKind::Error => "✖",
651                        DetailKind::Info => "ℹ",
652                        DetailKind::Note | DetailKind::Faded => "•",
653                    };
654                    writeln!(result, "{} {}", bullet, detail.content.as_str()).unwrap();
655                }
656            }
657
658            // All hints (ariadne doesn't show hints)
659            for hint in &self.hints {
660                writeln!(result, "ℹ {}", hint.as_str()).unwrap();
661            }
662        }
663
664        result
665    }
666
667    /// Render this diagnostic message as a JSON value.
668    ///
669    /// Returns a structured JSON object with all fields:
670    /// ```json
671    /// {
672    ///   "kind": "error",
673    ///   "title": "Invalid input",
674    ///   "code": "Q-1-2", // quarto-error-code-audit-ignore
675    ///   "problem": "Values must be numeric",
676    ///   "details": [{"kind": "error", "content": "Found text in column 3"}],
677    ///   "hints": ["Convert to numbers first?"]
678    /// }
679    /// ```
680    ///
681    /// # Example
682    ///
683    /// ```
684    /// use quarto_error_reporting::DiagnosticMessage;
685    ///
686    /// let msg = DiagnosticMessage::error("Something went wrong");
687    /// let json = msg.to_json();
688    /// assert_eq!(json["kind"], "error");
689    /// assert_eq!(json["title"], "Something went wrong");
690    /// ```
691    pub fn to_json(&self) -> serde_json::Value {
692        use serde_json::json;
693
694        let kind_str = match self.kind {
695            DiagnosticKind::Error => "error",
696            DiagnosticKind::Warning => "warning",
697            DiagnosticKind::Info => "info",
698            DiagnosticKind::Note => "note",
699        };
700
701        let mut obj = json!({
702            "kind": kind_str,
703            "title": self.title,
704        });
705
706        // Add optional fields
707        if let Some(code) = &self.code {
708            obj["code"] = json!(code);
709        }
710
711        if let Some(problem) = &self.problem {
712            obj["problem"] = problem.to_json();
713        }
714
715        if !self.details.is_empty() {
716            let details: Vec<_> = self
717                .details
718                .iter()
719                .map(|d| {
720                    let detail_kind = match d.kind {
721                        DetailKind::Error => "error",
722                        DetailKind::Info => "info",
723                        DetailKind::Note => "note",
724                        DetailKind::Faded => "faded",
725                    };
726                    let mut detail_obj = json!({
727                        "kind": detail_kind,
728                        "content": d.content.to_json()
729                    });
730                    if let Some(location) = &d.location {
731                        detail_obj["location"] = json!(location);
732                    }
733                    detail_obj
734                })
735                .collect();
736            obj["details"] = json!(details);
737        }
738
739        if !self.hints.is_empty() {
740            let hints: Vec<_> = self.hints.iter().map(|h| h.to_json()).collect();
741            obj["hints"] = json!(hints);
742        }
743
744        if let Some(location) = &self.location {
745            obj["location"] = json!(location); // quarto-source-map::SourceInfo is Serialize
746        }
747
748        obj
749    }
750
751    /// Snap a mapped byte range onto UTF-8 character boundaries within
752    /// `content`, clamping it into the file and keeping `start <= end`.
753    ///
754    /// # Why the renderers need this
755    ///
756    /// Both source-context renderers slice the source by byte offset, and
757    /// both **panic** — they do not merely mis-render — on an offset that
758    /// falls inside a multi-byte character. Measured 2026-08-23 against the
759    /// versions this crate's lockfile resolves (ariadne 0.6.0,
760    /// annotate-snippets 0.12.16; the manifest declares only `0.6` and
761    /// `0.12`), rendering `"text: <span>Ask AI \u{2728}</span>"` in which
762    /// `\u{2728}` occupies bytes 19..22 and the offset each row names is
763    /// placed at 20 or 21 — both interior to that character:
764    ///
765    /// | offset placed mid-character | ariadne | annotate-snippets |
766    /// |---|---|---|
767    /// | label start   | panics, `write.rs:84`  | panics, `renderer/source_map.rs:71` |
768    /// | label end     | panics, `write.rs:102` | panics, `renderer/source_map.rs:98` |
769    /// | report anchor | panics, `write.rs:267` | n/a — this crate passes no separate anchor |
770    ///
771    /// The clamping half guards two further aborts that boundary-snapping
772    /// alone would not catch, measured the same way:
773    ///
774    /// | malformed range | ariadne | annotate-snippets |
775    /// |---|---|---|
776    /// | end past EOF          | tolerates (degraded excerpt) | panics, `renderer/source_map.rs:158` |
777    /// | inverted, `end < start` | panics, `lib.rs:145` (a plain `assert!`, so also in release) | `renderer/render.rs:1394` subtracts unchecked: panics only where overflow checks are on (debug/test), wraps silently in a default release build |
778    ///
779    /// Printing a diagnostic must never be able to kill a render, so we
780    /// normalize here rather than trusting the input.
781    ///
782    /// # What is actually load-bearing, and when
783    ///
784    /// **How much the snapping half is doing depends on what
785    /// `quarto-source-map` resolves to.** Since 0.1.2,
786    /// `FileInformation::offset_to_location` returns the *floored* offset
787    /// (`src/file_info.rs:116-125`:
788    /// `safe_offset` walks left onto a character boundary and is returned as
789    /// `Location.offset`; 0.1.0 and 0.1.1 computed `safe_offset` but returned
790    /// the raw `offset`). **This crate's declared floor is still
791    /// `quarto-source-map = "0.1.0"` (`Cargo.toml:28`)**, so a consumer that
792    /// resolves 0.1.0 or 0.1.1 — an existing lock, another graph member
793    /// pinning `=0.1.1`, `-Z minimal-versions` — gets no upstream floor at
794    /// all, and for that build the snap below is the only guard rather than
795    /// a backstop. A published library's lockfile does not constrain its
796    /// consumers; downstream, q2 had to raise its own floor, because this
797    /// manifest does not force it.
798    ///
799    /// On a resolution that *does* have the floor, it covers the mapping
800    /// path broadly: every value-producing path of `SourceInfo::map_offset`
801    /// runs through `offset_to_location` (`src/mapping.rs:38`; `Generated`
802    /// yields `None` instead), and all three call sites here are fed
803    /// `map_offset` results — the span shared by the report anchor and the
804    /// main label, and the detail-label spans, both in
805    /// `render_ariadne_source_context`; and the `clamp` closure in
806    /// `render_annotate_snippets_source_context`.
807    ///
808    /// So a mid-character offset can no longer reach a renderer through the
809    /// mapping path **when the span resolves within a single file**. The
810    /// cross-file `Concat` shape described below is an exception for the
811    /// snapping half as well as the clamping half: `map_offset` floors an
812    /// offset against the piece's *own* file content (`src/mapping.rs:25-38`),
813    /// which says nothing about where character boundaries fall in
814    /// `content`.
815    ///
816    /// The snap is therefore kept deliberately. It is the only guard on a
817    /// pre-0.1.2 resolution, a live guard on the cross-file shape, and the
818    /// backstop if the upstream floor regresses or a future caller hands us
819    /// raw, unmapped offsets.
820    ///
821    /// **The clamping half is still live.** `end_mapped` is not always the
822    /// mapped image of this span's end: when `map_offset(length())` fails,
823    /// both renderer paths substitute `map_offset(length() - 1)` and then
824    /// fall back to `start_mapped` (the `length() - 1` fallback at
825    /// `:842-852` in `render_ariadne_source_context` and `:1038-1047` in
826    /// `render_annotate_snippets_source_context`, line numbers as of 0.2.2).
827    /// And `content` belongs to `root_file_id()`, which for a `Concat` is
828    /// the file of the *first* piece that resolves to one
829    /// (`quarto-source-map`'s `src/source_info.rs:549-560`), while
830    /// `map_offset` resolves into whichever piece contains the offset.
831    /// Structurally, then — a `Concat` spanning two files — the two ends
832    /// can resolve into different files, whose offsets are neither ordered
833    /// with respect to each other nor bounded by `content.len()`. (That
834    /// shape is permitted by the types; unlike the panics tabled above it
835    /// has not been exercised here.) `start.min(len)`, `end.min(len)` and
836    /// `.max(s)` reduce any of that to an in-range, non-inverted span.
837    ///
838    /// # Behaviour
839    ///
840    /// The range is widened, not truncated: `start` floors to the start of
841    /// the character containing it and `end` ceils to the end of the
842    /// character containing it, so the highlight covers whole characters
843    /// and can never invert. This differs from the upstream floor, which
844    /// walks an end offset *left* rather than widening it; the two agree
845    /// whenever only the start is misaligned.
846    ///
847    /// # Coverage
848    ///
849    /// The direct unit coverage is `snap_span_widens_to_whole_characters`.
850    /// The two `..._renders_diagnostic_with_originally_mid_character_span`
851    /// tests are end-to-end smoke checks only, and do not bind to this
852    /// helper: commit `5e48166`, *"Re-anchor the mid-character-span crash
853    /// tests after the 0.1.3 floor"*, re-anchored them and records why. (It
854    /// is a commit of PR #5, which was squash-merged as `87f1d38`, so it is
855    /// reachable through that PR rather than from `main`'s history.)
856    ///
857    /// Downstream, the quarto-dev/q2 repository is adding an end-to-end pin
858    /// for the founding crash, under `crates/quarto/tests/integration/`: it
859    /// drives the real `q2` binary over a website project whose
860    /// `_quarto.yml` navbar entry embeds `\u{2728}` **mid-string**, as
861    /// `text: '<span id="x">Ask AI \u{2728}</span>'`, and asserts a clean
862    /// exit alongside the expected caret columns. The trailing `</span>` is
863    /// load-bearing — one asserted caret falls past it — so it is the same
864    /// shape as this crate's own tests, where `\u{2728}` sits at bytes
865    /// 19..22 and `</span>` at 22..29. That pin will redden only if q2's
866    /// mapping regresses *and* both this snap and the upstream floor are
867    /// gone: it guards the combination, not this helper on its own.
868    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
869    fn snap_span_to_char_boundaries(
870        content: &str,
871        start: usize,
872        end: usize,
873    ) -> std::ops::Range<usize> {
874        let len = content.len();
875        let mut s = start.min(len);
876        let mut e = end.min(len).max(s);
877        while s > 0 && !content.is_char_boundary(s) {
878            s -= 1;
879        }
880        while e < len && !content.is_char_boundary(e) {
881            e += 1;
882        }
883        s..e
884    }
885
886    /// Dispatch to the selected source-context renderer.
887    ///
888    /// `renderer` of `None` resolves to [`SourceRenderer::default_for_features`].
889    /// Returns `None` when no renderer is available (no renderer feature
890    /// enabled) or the chosen renderer could not draw the excerpt (e.g.
891    /// the file content is unavailable — common in WASM), in which case
892    /// the caller falls back to the structured text block.
893    #[cfg_attr(
894        not(any(feature = "ariadne", feature = "annotate-snippets")),
895        allow(unused_variables)
896    )]
897    fn render_source_context(
898        &self,
899        main_location: &quarto_source_map::SourceInfo,
900        ctx: &quarto_source_map::SourceContext,
901        options: &TextRenderOptions,
902        renderer: Option<SourceRenderer>,
903    ) -> Option<String> {
904        let renderer = renderer.or_else(SourceRenderer::default_for_features)?;
905        match renderer {
906            #[cfg(feature = "ariadne")]
907            SourceRenderer::Ariadne => {
908                self.render_ariadne_source_context(main_location, ctx, options)
909            }
910            #[cfg(feature = "annotate-snippets")]
911            SourceRenderer::AnnotateSnippets => {
912                self.render_annotate_snippets_source_context(main_location, ctx, options)
913            }
914        }
915    }
916
917    /// Wrap a file path with OSC 8 ANSI hyperlink codes for clickable terminal links.
918    ///
919    /// OSC 8 is a terminal escape sequence that creates clickable hyperlinks:
920    /// `\x1b]8;;URI\x1b\\TEXT\x1b\\`
921    ///
922    /// `path` is always the *displayed* text (for a virtual file, the
923    /// cell-qualified label). `link_target` is the disk path the hyperlink
924    /// opens — normally `path` itself when it exists on disk, or the owning
925    /// notebook for a virtual file that carries a `FileOrigin`. `None`
926    /// disables the link.
927    ///
928    /// A link is emitted only if:
929    /// - Hyperlinks are enabled via the `enable_hyperlinks` parameter
930    /// - `link_target` is `Some` and can be canonicalized
931    ///
932    /// The `url` crate handles:
933    /// - Platform differences (Windows drive letters vs Unix paths)
934    /// - Percent-encoding of special characters
935    /// - Proper file:// URL construction
936    ///
937    /// Line and column numbers are added to the URL as a fragment identifier
938    /// (e.g., `file:///path#line:column`), which is supported by iTerm2 3.4+
939    /// and other terminal emulators for opening files at specific positions.
940    /// The fragment is emitted only when the position belongs to the linked
941    /// file itself (`link_target == path`); an origin link opens a different
942    /// file whose coordinates we cannot speak to.
943    ///
944    /// Returns the wrapped path if conditions are met, otherwise returns the original path.
945    ///
946    /// Only used by the ariadne renderer (annotate-snippets has no OSC 8 support).
947    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
948    fn wrap_path_with_hyperlink(
949        path: &str,
950        link_target: Option<&str>,
951        line: Option<usize>,
952        column: Option<usize>,
953        enable_hyperlinks: bool,
954    ) -> String {
955        // Don't add hyperlinks if disabled (e.g., for snapshot testing)
956        if !enable_hyperlinks {
957            return path.to_string();
958        }
959
960        let Some(target) = link_target else {
961            return path.to_string();
962        };
963
964        // Canonicalize to absolute path
965        let abs_path = match std::fs::canonicalize(target) {
966            Ok(p) => p,
967            Err(_) => return path.to_string(), // Can't canonicalize, skip hyperlink
968        };
969
970        // Convert to file:// URL (handles Windows/Unix + percent-encoding)
971        let mut file_url = match url::Url::from_file_path(Self::plain_absolute_path(abs_path)) {
972            Ok(url) => url.as_str().to_string(),
973            Err(_) => return path.to_string(), // Conversion failed, skip hyperlink
974        };
975
976        // Add line and column as fragment identifier (e.g., #line:column)
977        // This format is supported by iTerm2 3.4+ semantic history — but
978        // only when the position belongs to the linked file itself. An
979        // origin link opens a *different* file (the owning notebook), and
980        // the diagnostic's coordinates are relative to the virtual file.
981        if target == path
982            && let Some(line_num) = line
983        {
984            match column {
985                Some(col_num) => file_url.push_str(&format!("#{}:{}", line_num, col_num)),
986                None => file_url.push_str(&format!("#{}", line_num)),
987            }
988        }
989
990        // Wrap with OSC 8 codes: \x1b]8;;URI\x1b\\TEXT\x1b]8;;\x1b\\
991        format!("\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\", file_url, path)
992    }
993
994    /// The absolute path to hand to `url::Url::from_file_path`.
995    ///
996    /// Gated with `wrap_path_with_hyperlink` (its only production caller);
997    /// unit tests call it so the expected URL is computed the same way the
998    /// production renderer does. Windows: `fs::canonicalize` returns
999    /// verbatim (`\\?\C:\…`) paths, which the url crate renders as
1000    /// `file://?/C:/…` — no terminal can open that. Strip the verbatim
1001    /// prefix (`\\?\UNC\` → `\\`) to get the plain absolute form.
1002    #[cfg(all(feature = "ariadne", not(target_family = "wasm")))]
1003    fn plain_absolute_path(p: std::path::PathBuf) -> std::path::PathBuf {
1004        #[cfg(windows)]
1005        {
1006            let s = p.as_os_str().to_string_lossy();
1007            if let Some(rest) = s.strip_prefix(r#"\\?\UNC\"#) {
1008                return std::path::PathBuf::from(format!(r"\\{rest}"));
1009            }
1010            if let Some(rest) = s.strip_prefix(r#"\\?\"#) {
1011                return std::path::PathBuf::from(rest);
1012            }
1013        }
1014        p
1015    }
1016
1017    /// The disk path a file's label should hyperlink to: the owning
1018    /// notebook for a virtual file with a `FileOrigin`, the file itself
1019    /// when it exists on disk, `None` otherwise (ephemeral or missing).
1020    #[cfg(feature = "ariadne")]
1021    fn hyperlink_target(file: &quarto_source_map::SourceFile) -> Option<&str> {
1022        match file.metadata.origin.as_ref() {
1023            Some(quarto_source_map::FileOrigin::NotebookCell { notebook_path, .. }) => {
1024                Some(notebook_path)
1025            }
1026            None => std::path::Path::new(&file.path)
1027                .exists()
1028                .then_some(file.path.as_str()),
1029        }
1030    }
1031
1032    /// WASM version: hyperlinks don't make sense in WASM environments (no file system).
1033    /// Just return the path unmodified.
1034    #[cfg(all(feature = "ariadne", target_family = "wasm"))]
1035    fn wrap_path_with_hyperlink(
1036        path: &str,
1037        _link_target: Option<&str>,
1038        _line: Option<usize>,
1039        _column: Option<usize>,
1040        _enable_hyperlinks: bool,
1041    ) -> String {
1042        path.to_string()
1043    }
1044
1045    /// One-line textual location for a span whose two ends resolve into
1046    /// different `Concat` pieces, rendered in place of a snippet. A
1047    /// cross-piece span cannot be drawn as one excerpt, and clamping it
1048    /// into either piece's content would point at the wrong file.
1049    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
1050    fn format_cross_piece_location(
1051        start: &quarto_source_map::MappedLocation,
1052        end: &quarto_source_map::MappedLocation,
1053        ctx: &quarto_source_map::SourceContext,
1054    ) -> String {
1055        let name = |file_id| match ctx.get_file(file_id) {
1056            Some(file) => file.path.clone(),
1057            None => "<unknown file>".to_string(),
1058        };
1059        // Line and column numbers are 1-indexed for display (Location uses 0-indexed)
1060        format!(
1061            "  --> {}:{}:{} (spans through {}:{}:{})\n",
1062            name(start.file_id),
1063            start.location.row + 1,
1064            start.location.column + 1,
1065            name(end.file_id),
1066            end.location.row + 1,
1067            end.location.column + 1,
1068        )
1069    }
1070
1071    /// Render source context using ariadne (private helper for to_text).
1072    ///
1073    /// This produces the visual source code snippet with highlighting.
1074    /// The tidyverse-style problem/details/hints are added separately by to_text().
1075    ///
1076    /// With `options.enable_color` off, no SGR escapes are emitted:
1077    /// `Config::with_color(false)` disables ariadne's own colors and also
1078    /// strips the color from each label as it is added to the report.
1079    #[cfg(feature = "ariadne")]
1080    fn render_ariadne_source_context(
1081        &self,
1082        main_location: &quarto_source_map::SourceInfo,
1083        ctx: &quarto_source_map::SourceContext,
1084        options: &TextRenderOptions,
1085    ) -> Option<String> {
1086        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
1087
1088        let enable_hyperlinks = options.enable_hyperlinks;
1089
1090        // Mirror of ariadne's private `Config::unimportant_color()` from
1091        // ariadne 0.6.0 (`src/lib.rs:543`). We use this for `DetailKind::Faded`
1092        // labels so they blend visually with characters that fall outside any
1093        // label. Bump this constant if the ariadne dependency upgrades and
1094        // changes the colour.
1095        const ARIADNE_UNIMPORTANT_COLOR: Color = Color::Fixed(249);
1096
1097        // The report's file is the one the span *starts* in. For a
1098        // multi-piece `Concat` (q2's per-cell ipynb virtual files),
1099        // `root_file_id()` is the first piece's file — wrong for a
1100        // diagnostic rooted in a later piece — while `map_offset`
1101        // resolves piece-aware. `start_mapped.file_id` is correct for
1102        // both shapes (and identical to `root_file_id()` on a
1103        // single-file source).
1104        let start_mapped = main_location.map_offset(0, ctx)?;
1105        let file_id = start_mapped.file_id;
1106
1107        let file = ctx.get_file(file_id)?;
1108
1109        // Get file content: use stored content for ephemeral files, or read from disk.
1110        // In WASM (and any host with no real filesystem) the disk read fails with
1111        // "operation not supported on this platform"; the only graceful response is
1112        // to drop the source-context snippet. The diagnostic's code, message, and
1113        // hints still surface — only the Ariadne visual is unavailable.
1114        let content = match &file.content {
1115            Some(c) => c.clone(),
1116            None => match std::fs::read_to_string(&file.path) {
1117                Ok(s) => s,
1118                Err(_) => return None,
1119            },
1120        };
1121
1122        // For end offset, try the full length first. If that fails (e.g., when the span
1123        // extends past EOF), clamp to the last valid position. This handles edge cases
1124        // like errors pointing to EOF or diagnostics with off-by-one end offsets.
1125        let end_mapped = main_location
1126            .map_offset(main_location.length(), ctx)
1127            .or_else(|| {
1128                // Clamp: if length() fails, try length()-1, which should be the last valid byte
1129                if main_location.length() > 0 {
1130                    main_location.map_offset(main_location.length() - 1, ctx)
1131                } else {
1132                    None
1133                }
1134            })
1135            .unwrap_or_else(|| start_mapped.clone());
1136
1137        // A span whose two ends resolve into different pieces cannot be
1138        // drawn as one snippet — rendering it against either piece's
1139        // content would clamp it silently into the wrong file. Label both
1140        // ends textually instead.
1141        if end_mapped.file_id != start_mapped.file_id {
1142            return Some(Self::format_cross_piece_location(
1143                &start_mapped,
1144                &end_mapped,
1145                ctx,
1146            ));
1147        }
1148
1149        // Create display path with OSC 8 hyperlink for clickable file paths.
1150        // A virtual cell file (FileOrigin) links its owning notebook; a real
1151        // file links itself; an ephemeral file with no origin stays unlinked.
1152        let link_target = Self::hyperlink_target(file);
1153        // Line and column numbers are 1-indexed for display (start_mapped.location uses 0-indexed)
1154        let line = Some(start_mapped.location.row + 1);
1155        let column = Some(start_mapped.location.column + 1);
1156        let display_path = Self::wrap_path_with_hyperlink(
1157            &file.path,
1158            link_target,
1159            line,
1160            column,
1161            enable_hyperlinks,
1162        );
1163
1164        // Determine report kind and color
1165        let (report_kind, main_color) = match self.kind {
1166            DiagnosticKind::Error => (ReportKind::Error, Color::Red),
1167            DiagnosticKind::Warning => (ReportKind::Warning, Color::Yellow),
1168            DiagnosticKind::Info => (ReportKind::Advice, Color::Cyan),
1169            DiagnosticKind::Note => (ReportKind::Advice, Color::Blue),
1170        };
1171
1172        // Snap once, up front: every offset handed to ariadne below (the
1173        // report anchor and the main label) must be char-boundary safe.
1174        let main_span = Self::snap_span_to_char_boundaries(
1175            &content,
1176            start_mapped.location.offset,
1177            end_mapped.location.offset,
1178        );
1179
1180        // Build the report using the mapped offset for proper line:column display
1181        // IMPORTANT: Use IndexType::Byte because our offsets are byte offsets, not character offsets
1182        //
1183        // The config must be set before any label is added: ariadne applies
1184        // the config's color switch to each label's color at `add_label`
1185        // time (ariadne 0.6.0 `Report::add_labels`), so labels added under
1186        // the default config would keep their colors with color off.
1187        let mut report = Report::build(
1188            report_kind,
1189            (display_path.clone(), main_span.start..main_span.start),
1190        )
1191        .with_config(
1192            Config::default()
1193                .with_index_type(IndexType::Byte)
1194                .with_color(options.enable_color),
1195        );
1196
1197        // Add title with error code
1198        if let Some(code) = &self.code {
1199            report = report.with_message(format!("[{}] {}", code, self.title));
1200        } else {
1201            report = report.with_message(&self.title);
1202        }
1203
1204        // Add main location label using the snapped span computed above.
1205        let main_message = if let Some(problem) = &self.problem {
1206            problem.as_str()
1207        } else {
1208            &self.title
1209        };
1210
1211        // Set `with_order` on every label using its end offset. Ariadne
1212        // groups labels by source and starts a new group whenever a label's
1213        // end line is *before* the previous label's end line. Without an
1214        // explicit order, multi-line main labels and per-line "padding"
1215        // detail labels (used to defeat Ariadne's middle-line elision) end
1216        // up in separate groups, producing a duplicated snippet block.
1217        // Sorting by end offset puts the smaller-line labels first so the
1218        // grouping algorithm extends rather than splits.
1219        report = report.with_label(
1220            Label::new((display_path.clone(), main_span.clone()))
1221                .with_message(main_message)
1222                .with_color(main_color)
1223                .with_order(main_span.end as i32),
1224        );
1225
1226        // Add detail locations as additional labels (only those with locations).
1227        // Details rooted (start and end) in the report's file stay inline
1228        // labels; details rooted wholly in another piece are collected and
1229        // appended after the loop as labels in their own file's source
1230        // section. A detail whose own span straddles pieces is not
1231        // representable as one inline label and is skipped (the main-span
1232        // cross-piece label above covers the common cross-cell shape).
1233        let mut cache_files = vec![(display_path.clone(), Source::from(content.clone()))];
1234        let mut foreign_labels: Vec<(String, std::ops::Range<usize>, Option<String>, Color)> =
1235            Vec::new();
1236        for detail in &self.details {
1237            if let Some(detail_loc) = &detail.location {
1238                // Map detail offsets to original file positions
1239                // map_offset expects relative offsets (0 = start of SourceInfo's range)
1240                let (Some(detail_start), Some(detail_end)) = (
1241                    detail_loc.map_offset(0, ctx),
1242                    detail_loc.map_offset(detail_loc.length(), ctx),
1243                ) else {
1244                    continue;
1245                };
1246                let detail_color = match detail.kind {
1247                    DetailKind::Error => Color::Red,
1248                    DetailKind::Info => Color::Cyan,
1249                    DetailKind::Note => Color::Blue,
1250                    // Match Ariadne's unimportant colour so faded
1251                    // labels visually disappear into the surrounding
1252                    // unlabelled text.
1253                    DetailKind::Faded => ARIADNE_UNIMPORTANT_COLOR,
1254                };
1255
1256                if detail_start.file_id == detail_end.file_id && detail_start.file_id != file_id {
1257                    // Wholly inside another piece: render it there as its
1258                    // own source section instead of silently dropping it.
1259                    let Some(detail_file) = ctx.get_file(detail_start.file_id) else {
1260                        continue;
1261                    };
1262                    let Some(detail_content) = detail_file
1263                        .content
1264                        .clone()
1265                        .or_else(|| std::fs::read_to_string(&detail_file.path).ok())
1266                    else {
1267                        continue;
1268                    };
1269                    // File-level id (no per-line hyperlink fragment) so
1270                    // several details in one foreign file share a source
1271                    // section; ariadne derives the header line:column from
1272                    // the label span itself.
1273                    let foreign_display = Self::wrap_path_with_hyperlink(
1274                        &detail_file.path,
1275                        Self::hyperlink_target(detail_file),
1276                        None,
1277                        None,
1278                        enable_hyperlinks,
1279                    );
1280                    let detail_span = Self::snap_span_to_char_boundaries(
1281                        &detail_content,
1282                        detail_start.location.offset,
1283                        detail_end.location.offset,
1284                    );
1285                    if !cache_files.iter().any(|(path, _)| *path == foreign_display) {
1286                        cache_files.push((foreign_display.clone(), Source::from(detail_content)));
1287                    }
1288                    let message = (!detail.content.as_str().is_empty())
1289                        .then(|| detail.content.as_str().to_string());
1290                    foreign_labels.push((foreign_display, detail_span, message, detail_color));
1291                    continue;
1292                }
1293
1294                if detail_start.file_id == file_id && detail_end.file_id == file_id {
1295                    let detail_span = Self::snap_span_to_char_boundaries(
1296                        &content,
1297                        detail_start.location.offset,
1298                        detail_end.location.offset,
1299                    );
1300                    // Empty-content details exist purely to force Ariadne
1301                    // to display a line that would otherwise be elided
1302                    // inside a multi-line span. Leaving the label's
1303                    // message at None makes Ariadne skip drawing the
1304                    // `╰── ...` arrow row underneath, so the source line
1305                    // appears clean.
1306                    let mut label = Label::new((display_path.clone(), detail_span.clone()))
1307                        .with_color(detail_color)
1308                        .with_order(detail_span.end as i32);
1309                    if !detail.content.as_str().is_empty() {
1310                        label = label.with_message(detail.content.as_str());
1311                    }
1312                    report = report.with_label(label);
1313                }
1314            }
1315        }
1316
1317        // Foreign-piece labels sort after every same-file label so
1318        // ariadne's order-keyed grouping draws the report's file first,
1319        // intact; no realistic same-file span end reaches this base.
1320        for (foreign_order, (path, span, message, color)) in (1_000_000i32..).zip(foreign_labels) {
1321            let mut label = Label::new((path, span))
1322                .with_color(color)
1323                .with_order(foreign_order);
1324            if let Some(message) = message {
1325                label = label.with_message(message);
1326            }
1327            report = report.with_label(label);
1328        }
1329
1330        // Render to string
1331        let report = report.finish();
1332        let mut output = Vec::new();
1333        report
1334            .write(ContextSourceCache { files: cache_files }, &mut output)
1335            .ok()?;
1336
1337        let output_str = String::from_utf8(output).ok()?;
1338
1339        // Post-process to extend hyperlinks to include line:column numbers
1340        // Ariadne adds :line:column after our hyperlinked path, so we need to
1341        // move the hyperlink end marker to include those numbers. Only for
1342        // self-links: an origin link opens a different file, and the
1343        // appended coordinates are relative to the virtual file — they
1344        // must not leak into the notebook URL.
1345        if enable_hyperlinks && link_target == Some(file.path.as_str()) {
1346            Some(Self::extend_hyperlink_to_include_line_column(
1347                &output_str,
1348                &file.path,
1349            ))
1350        } else {
1351            Some(output_str)
1352        }
1353    }
1354
1355    /// Render source context using [`annotate-snippets`](https://crates.io/crates/annotate-snippets),
1356    /// the rust-lang toolchain's diagnostic style (private helper for to_text).
1357    ///
1358    /// Mirrors [`Self::render_ariadne_source_context`]'s offset-mapping
1359    /// logic but emits the `error[CODE]: …` / `-->` / gutter-bar look.
1360    /// Differences from the ariadne path, by design:
1361    ///
1362    /// - The error code is rendered natively via `Title::id` (e.g.
1363    ///   `error[Q-2-5]: …`) rather than prefixed into the message.
1364    /// - There are **no terminal hyperlinks** — annotate-snippets has no
1365    ///   OSC 8 support, so `options.enable_hyperlinks` is ignored.
1366    ///   `options.enable_color` selects `Renderer::styled()` (ANSI) or
1367    ///   `Renderer::plain()` (no escape sequences).
1368    /// - Detail labels are all rendered as `Context` annotations
1369    ///   (annotate-snippets has no per-label color), so the `DetailKind`
1370    ///   color distinction and the `Faded` blend are not reproduced.
1371    /// - Empty-content "padding" details (an ariadne workaround for
1372    ///   mid-span line elision) are skipped: annotate-snippets folds
1373    ///   unannotated lines natively, which is the look we want here.
1374    #[cfg(feature = "annotate-snippets")]
1375    fn render_annotate_snippets_source_context(
1376        &self,
1377        main_location: &quarto_source_map::SourceInfo,
1378        ctx: &quarto_source_map::SourceContext,
1379        options: &TextRenderOptions,
1380    ) -> Option<String> {
1381        use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet};
1382
1383        // The report's file is the one the span *starts* in — same
1384        // contract as the ariadne path: `root_file_id()` is the first
1385        // `Concat` piece's file, wrong for a diagnostic rooted in a
1386        // later piece, while `map_offset` resolves piece-aware.
1387        let start_mapped = main_location.map_offset(0, ctx)?;
1388        let file_id = start_mapped.file_id;
1389
1390        // A span whose two ends resolve into different pieces cannot be
1391        // drawn as one snippet; label both ends textually rather than
1392        // silently clamping into one piece's content.
1393        let end_mapped = main_location
1394            .map_offset(main_location.length(), ctx)
1395            .or_else(|| {
1396                if main_location.length() > 0 {
1397                    main_location.map_offset(main_location.length() - 1, ctx)
1398                } else {
1399                    None
1400                }
1401            })
1402            .unwrap_or_else(|| start_mapped.clone());
1403        if end_mapped.file_id != start_mapped.file_id {
1404            return Some(Self::format_cross_piece_location(
1405                &start_mapped,
1406                &end_mapped,
1407                ctx,
1408            ));
1409        }
1410
1411        let file = ctx.get_file(file_id)?;
1412        let content = match &file.content {
1413            Some(c) => c.clone(),
1414            None => std::fs::read_to_string(&file.path).ok()?,
1415        };
1416        // Clamp a mapped byte range into the source, keeping start <= end
1417        // and both ends on UTF-8 character boundaries (annotate-snippets
1418        // panics on a mid-character offset just as ariadne does).
1419        let clamp = |start: usize, end: usize| -> std::ops::Range<usize> {
1420            Self::snap_span_to_char_boundaries(&content, start, end)
1421        };
1422
1423        let main_span = clamp(start_mapped.location.offset, end_mapped.location.offset);
1424
1425        let level = match self.kind {
1426            DiagnosticKind::Error => Level::ERROR,
1427            DiagnosticKind::Warning => Level::WARNING,
1428            DiagnosticKind::Info => Level::INFO,
1429            DiagnosticKind::Note => Level::NOTE,
1430        };
1431
1432        // Primary label message: the problem statement, else the title.
1433        let main_message = match &self.problem {
1434            Some(problem) => problem.as_str(),
1435            None => self.title.as_str(),
1436        };
1437
1438        let mut snippet = Snippet::source(content.as_str())
1439            .path(file.path.as_str())
1440            .line_start(1)
1441            .annotation(AnnotationKind::Primary.span(main_span).label(main_message));
1442
1443        // Details rooted (start and end) in the report's file become
1444        // Context annotations; details rooted wholly in another piece
1445        // render as their own snippet element below the main one instead
1446        // of being silently dropped. A detail whose own span straddles
1447        // pieces is not representable as one annotation and is skipped
1448        // (the main-span cross-piece label covers the common shape).
1449        let mut foreign: Vec<(String, String, std::ops::Range<usize>, &str)> = Vec::new();
1450        for detail in &self.details {
1451            // Skip empty-content padding details (see the doc comment).
1452            if detail.content.as_str().is_empty() {
1453                continue;
1454            }
1455            let Some(detail_loc) = &detail.location else {
1456                continue;
1457            };
1458            let (Some(detail_start), Some(detail_end)) = (
1459                detail_loc.map_offset(0, ctx),
1460                detail_loc.map_offset(detail_loc.length(), ctx),
1461            ) else {
1462                continue;
1463            };
1464            if detail_start.file_id == detail_end.file_id && detail_start.file_id != file_id {
1465                let Some(detail_file) = ctx.get_file(detail_start.file_id) else {
1466                    continue;
1467                };
1468                let Some(detail_content) = detail_file
1469                    .content
1470                    .clone()
1471                    .or_else(|| std::fs::read_to_string(&detail_file.path).ok())
1472                else {
1473                    continue;
1474                };
1475                let detail_span = Self::snap_span_to_char_boundaries(
1476                    &detail_content,
1477                    detail_start.location.offset,
1478                    detail_end.location.offset,
1479                );
1480                foreign.push((
1481                    detail_file.path.clone(),
1482                    detail_content,
1483                    detail_span,
1484                    detail.content.as_str(),
1485                ));
1486                continue;
1487            }
1488            if detail_start.file_id == file_id && detail_end.file_id == file_id {
1489                let detail_span = clamp(detail_start.location.offset, detail_end.location.offset);
1490                snippet = snippet.annotation(
1491                    AnnotationKind::Context
1492                        .span(detail_span)
1493                        .label(detail.content.as_str()),
1494                );
1495            }
1496        }
1497
1498        // Build the titled group; render the error code natively via `id`,
1499        // then append each foreign-piece detail as its own snippet element
1500        // (annotate-snippets draws one excerpt per element, each with its
1501        // own `--> path` header).
1502        let mut title = level.primary_title(self.title.as_str());
1503        if let Some(code) = &self.code {
1504            title = title.id(code.as_str());
1505        }
1506        let mut group = title.element(snippet);
1507        // Iterate by reference: the built `Snippet`s borrow `path`/`content`,
1508        // so the owned strings must stay alive in `foreign` until `render`.
1509        for (path, content, span, message) in &foreign {
1510            let extra = Snippet::source(content.as_str())
1511                .path(path)
1512                .line_start(1)
1513                .annotation(AnnotationKind::Context.span(span.clone()).label(*message));
1514            group = group.element(extra);
1515        }
1516
1517        // `Renderer::render` returns text with no trailing newline, but
1518        // `to_text` appends unlocated details and hints directly after the
1519        // excerpt with `writeln!`. Match the ariadne path (which ends in a
1520        // newline) so those lines don't glue onto the last source row.
1521        let renderer = if options.enable_color {
1522            Renderer::styled()
1523        } else {
1524            Renderer::plain()
1525        };
1526        let mut rendered = renderer.render(&[group]);
1527        if !rendered.ends_with('\n') {
1528            rendered.push('\n');
1529        }
1530        Some(rendered)
1531    }
1532
1533    /// Extend OSC 8 hyperlinks to include the :line:column suffix that ariadne adds.
1534    ///
1535    /// Ariadne formats file references as `path:line:column`, but since we wrap the path
1536    /// with OSC 8 codes, the structure becomes: `[hyperlink:path]:line:column`
1537    /// We want: `[hyperlink:path:line:column]`
1538    ///
1539    /// This function finds patterns like `path]8;;\:line:column` and moves the hyperlink
1540    /// end marker to after the line:column part.
1541    #[cfg(feature = "ariadne")]
1542    fn extend_hyperlink_to_include_line_column(output: &str, original_path: &str) -> String {
1543        // Pattern: original_path followed by ]8;;\ then :numbers:numbers
1544        // We want to move the ]8;;\ to after the :numbers:numbers part
1545        let end_marker = "\x1b]8;;\x1b\\";
1546        let search_pattern = format!("{}{}", original_path, end_marker);
1547
1548        let mut result = output.to_string();
1549        while let Some(pos) = result.find(&search_pattern) {
1550            let after_marker = pos + search_pattern.len();
1551            // Check if what follows is :line:column pattern
1552            if let Some(rest) = result.get(after_marker..) {
1553                // Match :digits:digits pattern
1554                if let Some(colon_end) = Self::find_line_column_end(rest) {
1555                    // Move the end marker to after the :line:column
1556                    let before = &result[..pos + original_path.len()];
1557                    let line_col = &rest[..colon_end];
1558                    let after = &rest[colon_end..];
1559                    result = format!("{}{}{}{}", before, line_col, end_marker, after);
1560                    continue;
1561                }
1562            }
1563            break;
1564        }
1565        result
1566    }
1567
1568    /// Find the end position of a :line:column pattern at the start of the string.
1569    /// Returns None if the pattern doesn't match.
1570    #[cfg(feature = "ariadne")]
1571    fn find_line_column_end(s: &str) -> Option<usize> {
1572        let bytes = s.as_bytes();
1573        if bytes.is_empty() || bytes[0] != b':' {
1574            return None;
1575        }
1576
1577        let mut pos = 1;
1578        // Read digits for line number
1579        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1580            pos += 1;
1581        }
1582        if pos == 1 || pos >= bytes.len() || bytes[pos] != b':' {
1583            return None; // No digits or no second colon
1584        }
1585
1586        pos += 1; // Skip second colon
1587        let col_start = pos;
1588        // Read digits for column number
1589        while pos < bytes.len() && bytes[pos].is_ascii_digit() {
1590            pos += 1;
1591        }
1592        if pos == col_start {
1593            return None; // No digits for column
1594        }
1595
1596        Some(pos)
1597    }
1598}
1599
1600#[cfg(test)]
1601mod tests {
1602    use super::*;
1603
1604    #[test]
1605    fn test_diagnostic_kind() {
1606        assert_eq!(DiagnosticKind::Error, DiagnosticKind::Error);
1607        assert_ne!(DiagnosticKind::Error, DiagnosticKind::Warning);
1608    }
1609
1610    #[test]
1611    fn test_message_content_from_str() {
1612        let content: MessageContent = "test".into();
1613        assert_eq!(content.as_str(), "test");
1614    }
1615
1616    #[test]
1617    fn test_diagnostic_message_new() {
1618        let msg = DiagnosticMessage::new(DiagnosticKind::Error, "Test error");
1619        assert_eq!(msg.title, "Test error");
1620        assert_eq!(msg.kind, DiagnosticKind::Error);
1621        assert!(msg.code.is_none());
1622        assert!(msg.problem.is_none());
1623        assert!(msg.details.is_empty());
1624        assert!(msg.hints.is_empty());
1625    }
1626
1627    #[test]
1628    fn test_diagnostic_message_constructors() {
1629        let error = DiagnosticMessage::error("Error");
1630        assert_eq!(error.kind, DiagnosticKind::Error);
1631        assert!(error.code.is_none());
1632
1633        let warning = DiagnosticMessage::warning("Warning");
1634        assert_eq!(warning.kind, DiagnosticKind::Warning);
1635
1636        let info = DiagnosticMessage::info("Info");
1637        assert_eq!(info.kind, DiagnosticKind::Info);
1638    }
1639
1640    #[test]
1641    fn test_with_code() {
1642        let msg = DiagnosticMessage::error("Test error").with_code("Q-1-1");
1643        assert_eq!(msg.code, Some("Q-1-1".to_string()));
1644    }
1645
1646    // The positive case — `docs_url()` for a real code resolves to the
1647    // quarto.org URL — moved to `quarto-error-catalog`'s integration tests,
1648    // where the `Q-*` catalog is installed. Here we only cover the
1649    // catalog-free cases (no code / unknown code → `None`), which hold
1650    // regardless of whether a catalog is installed.
1651
1652    #[test]
1653    fn test_docs_url_without_code() {
1654        let msg = DiagnosticMessage::error("Test error");
1655        assert!(msg.docs_url().is_none());
1656    }
1657
1658    #[test]
1659    fn test_docs_url_invalid_code() {
1660        let msg = DiagnosticMessage::error("Test error").with_code("Q-999-999"); // quarto-error-code-audit-ignore
1661        assert!(msg.docs_url().is_none());
1662    }
1663
1664    #[test]
1665    fn test_to_text_simple_error() {
1666        let msg = DiagnosticMessage::error("Something went wrong");
1667        assert_eq!(msg.to_text(None), "Error: Something went wrong\n");
1668    }
1669
1670    #[test]
1671    fn test_to_text_with_code() {
1672        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1673        assert_eq!(msg.to_text(None), "Error [Q-1-1]: Something went wrong\n");
1674    }
1675
1676    #[test]
1677    fn test_to_text_full_message() {
1678        use crate::builder::DiagnosticMessageBuilder;
1679
1680        let msg = DiagnosticMessageBuilder::error("Invalid input")
1681            .problem("Values must be numeric")
1682            .add_detail("Found text in column 3")
1683            .add_info("Columns should contain only numbers")
1684            .add_hint("Convert to numbers first?")
1685            .build();
1686
1687        let text = msg.to_text(None);
1688        assert!(text.contains("Error: Invalid input"));
1689        assert!(text.contains("Values must be numeric"));
1690        assert!(text.contains("✖ Found text in column 3"));
1691        assert!(text.contains("ℹ Columns should contain only numbers"));
1692        assert!(text.contains("ℹ Convert to numbers first?"));
1693    }
1694
1695    #[test]
1696    fn test_to_json_simple() {
1697        let msg = DiagnosticMessage::error("Something went wrong");
1698        let json = msg.to_json();
1699
1700        assert_eq!(json["kind"], "error");
1701        assert_eq!(json["title"], "Something went wrong");
1702        assert!(json.get("code").is_none());
1703        assert!(json.get("problem").is_none());
1704    }
1705
1706    #[test]
1707    fn test_to_json_with_code() {
1708        let msg = DiagnosticMessage::error("Something went wrong").with_code("Q-1-1");
1709        let json = msg.to_json();
1710
1711        assert_eq!(json["kind"], "error");
1712        assert_eq!(json["title"], "Something went wrong");
1713        assert_eq!(json["code"], "Q-1-1");
1714    }
1715
1716    #[test]
1717    fn test_to_json_full_message() {
1718        use crate::builder::DiagnosticMessageBuilder;
1719
1720        let msg = DiagnosticMessageBuilder::error("Invalid input")
1721            .with_code("Q-1-2") // quarto-error-code-audit-ignore
1722            .problem("Values must be numeric")
1723            .add_detail("Found text in column 3")
1724            .add_info("Expected numbers")
1725            .add_hint("Convert to numbers first?")
1726            .build();
1727
1728        let json = msg.to_json();
1729        assert_eq!(json["kind"], "error");
1730        assert_eq!(json["title"], "Invalid input");
1731        assert_eq!(json["code"], "Q-1-2"); // quarto-error-code-audit-ignore
1732        assert_eq!(json["problem"]["type"], "markdown");
1733        assert_eq!(json["problem"]["content"], "Values must be numeric");
1734        assert_eq!(json["details"][0]["kind"], "error");
1735        assert_eq!(json["details"][0]["content"]["type"], "markdown");
1736        assert_eq!(
1737            json["details"][0]["content"]["content"],
1738            "Found text in column 3"
1739        );
1740        assert_eq!(json["details"][1]["kind"], "info");
1741        assert_eq!(json["details"][1]["content"]["type"], "markdown");
1742        assert_eq!(json["details"][1]["content"]["content"], "Expected numbers");
1743        assert_eq!(json["hints"][0]["type"], "markdown");
1744        assert_eq!(json["hints"][0]["content"], "Convert to numbers first?");
1745    }
1746
1747    #[test]
1748    fn test_to_json_warning() {
1749        let msg = DiagnosticMessage::warning("Be careful");
1750        let json = msg.to_json();
1751
1752        assert_eq!(json["kind"], "warning");
1753        assert_eq!(json["title"], "Be careful");
1754    }
1755
1756    #[test]
1757    fn test_location_in_to_text_without_context() {
1758        use crate::builder::DiagnosticMessageBuilder;
1759
1760        // Create a location at offsets 100-110
1761        let location =
1762            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1763
1764        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1765            .with_location(location)
1766            .build();
1767
1768        let text = msg.to_text(None);
1769
1770        // Without context, should show offset (we can't get row/column without context)
1771        assert!(text.contains("Invalid syntax"));
1772        assert!(text.contains("at offset 100"));
1773    }
1774
1775    #[test]
1776    fn test_location_in_to_text_with_context() {
1777        use crate::builder::DiagnosticMessageBuilder;
1778
1779        // Create a source context with a file
1780        let mut ctx = quarto_source_map::SourceContext::new();
1781        let file_id = ctx.add_file(
1782            "test.qmd".to_string(),
1783            Some("line 1\nline 2\nline 3\nline 4".to_string()),
1784        );
1785
1786        // Create a location in that file (offset 7 is start of "line 2")
1787        let location = quarto_source_map::SourceInfo::original(
1788            file_id, 7,  // Start of "line 2"
1789            13, // End of "line 2"
1790        );
1791
1792        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1793            .with_location(location)
1794            .build();
1795
1796        let text = msg.to_text(Some(&ctx));
1797
1798        // With context, should show file path and 1-indexed location
1799        assert!(text.contains("Invalid syntax"));
1800        assert!(text.contains("test.qmd"));
1801        assert!(text.contains("2:1")); // row 1 + 1, column 0 + 1
1802    }
1803
1804    #[test]
1805    fn test_location_in_to_json() {
1806        use crate::builder::DiagnosticMessageBuilder;
1807
1808        let location =
1809            quarto_source_map::SourceInfo::original(quarto_source_map::FileId(0), 100, 110);
1810
1811        let msg = DiagnosticMessageBuilder::error("Invalid syntax")
1812            .with_location(location)
1813            .build();
1814
1815        let json = msg.to_json();
1816
1817        // Should have location field with Original variant
1818        assert!(json.get("location").is_some());
1819        let loc = &json["location"];
1820
1821        // Verify the SourceInfo is serialized correctly (as Original enum variant)
1822        assert!(loc.get("Original").is_some());
1823        let original = &loc["Original"];
1824        assert_eq!(original["file_id"], 0);
1825        assert_eq!(original["start_offset"], 100);
1826        assert_eq!(original["end_offset"], 110);
1827    }
1828
1829    #[test]
1830    fn test_location_optional_in_to_json() {
1831        let msg = DiagnosticMessage::error("No location");
1832        let json = msg.to_json();
1833
1834        // Should not have location field when not provided
1835        assert!(json.get("location").is_none());
1836    }
1837
1838    #[test]
1839    fn test_text_render_options_disable_hyperlinks() {
1840        use crate::builder::DiagnosticMessageBuilder;
1841
1842        let mut ctx = quarto_source_map::SourceContext::new();
1843        let file_id = ctx.add_file("test.qmd".to_string(), Some("test content".to_string()));
1844
1845        let location = quarto_source_map::SourceInfo::original(file_id, 0, 4);
1846
1847        let msg = DiagnosticMessageBuilder::error("Test error")
1848            .with_location(location)
1849            .build();
1850
1851        // With hyperlinks enabled (default)
1852        let with_hyperlinks = msg.to_text(Some(&ctx));
1853
1854        // With hyperlinks disabled
1855        let options = TextRenderOptions::default().hyperlinks(false);
1856        let without_hyperlinks = msg.to_text_with_options(Some(&ctx), &options);
1857
1858        // When hyperlinks are disabled, output should be different
1859        // (specifically, no OSC 8 escape sequences)
1860        if with_hyperlinks.contains("\x1b]8;") {
1861            assert!(
1862                !without_hyperlinks.contains("\x1b]8;"),
1863                "Disabled hyperlinks should not contain OSC 8 codes"
1864            );
1865        }
1866    }
1867
1868    #[test]
1869    fn test_text_render_options_default() {
1870        let options = TextRenderOptions::default();
1871        assert!(
1872            options.enable_hyperlinks,
1873            "Default should enable hyperlinks"
1874        );
1875    }
1876
1877    #[test]
1878    fn test_render_with_custom_options() {
1879        use crate::builder::DiagnosticMessageBuilder;
1880
1881        let msg = DiagnosticMessageBuilder::error("Test")
1882            .problem("Something went wrong")
1883            .add_detail("Detail 1")
1884            .add_hint("Try this")
1885            .build();
1886
1887        let options = TextRenderOptions::default().hyperlinks(false);
1888
1889        let text = msg.to_text_with_options(None, &options);
1890
1891        // Should still render properly without hyperlinks
1892        assert!(text.contains("Error: Test"));
1893        assert!(text.contains("Something went wrong"));
1894        assert!(text.contains("Detail 1"));
1895        assert!(text.contains("Try this"));
1896    }
1897
1898    /// Strip CSI SGR color sequences (`ESC [ … m`). The annotate-snippets
1899    /// path emits no OSC 8 hyperlinks, so color is all we need to remove
1900    /// to make substring assertions robust to styling.
1901    #[cfg(feature = "annotate-snippets")]
1902    fn strip_ansi(s: &str) -> String {
1903        let mut out = String::new();
1904        let mut chars = s.chars().peekable();
1905        while let Some(c) = chars.next() {
1906            if c == '\u{1b}' {
1907                for n in chars.by_ref() {
1908                    if n == 'm' {
1909                        break;
1910                    }
1911                }
1912            } else {
1913                out.push(c);
1914            }
1915        }
1916        out
1917    }
1918
1919    /// The annotate-snippets renderer emits the rust-lang toolchain look:
1920    /// an `error[CODE]: …` header, a `-->` origin line, and `^` underlines
1921    /// — not ariadne's enclosing box.
1922    #[cfg(feature = "annotate-snippets")]
1923    #[test]
1924    fn annotate_snippets_renderer_produces_rust_style_output() {
1925        use crate::builder::DiagnosticMessageBuilder;
1926
1927        let mut ctx = quarto_source_map::SourceContext::new();
1928        let file_id = ctx.add_file(
1929            "test.qmd".to_string(),
1930            Some("line 1\nline 2\nline 3".to_string()),
1931        );
1932        // Offsets 7..13 cover "line 2" on row 2.
1933        let location = quarto_source_map::SourceInfo::original(file_id, 7, 13);
1934        let msg = DiagnosticMessageBuilder::error("Bad thing")
1935            .with_code("Q-9-9")
1936            .with_location(location)
1937            .problem("this is wrong")
1938            .build();
1939
1940        let opts = TextRenderOptions::default().hyperlinks(false);
1941        let raw =
1942            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
1943        let text = strip_ansi(&raw);
1944
1945        assert!(
1946            text.contains("error[Q-9-9]"),
1947            "expected rust-style code header; got: {text:?}"
1948        );
1949        assert!(
1950            text.contains("-->"),
1951            "expected rust-style origin arrow; got: {text:?}"
1952        );
1953        assert!(
1954            text.contains("test.qmd:2:1"),
1955            "expected mapped location; got: {text:?}"
1956        );
1957        assert!(
1958            !text.contains('\u{256D}'),
1959            "annotate-snippets must not draw ariadne's box corner; got: {text:?}"
1960        );
1961        // No OSC 8 hyperlinks from annotate-snippets.
1962        assert!(
1963            !raw.contains("\u{1b}]8;"),
1964            "annotate-snippets emits no OSC 8 hyperlinks; got: {raw:?}"
1965        );
1966    }
1967
1968    /// Direct coverage of the snapping helper's contract: clamp into the
1969    /// file, widen to whole characters, never invert.
1970    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
1971    #[test]
1972    fn snap_span_widens_to_whole_characters() {
1973        // `\u{2728}` occupies bytes 3..6.
1974        let content = "abc\u{2728}def";
1975        assert_eq!(content.len(), 9);
1976
1977        let snap = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content, s, e);
1978
1979        // Already aligned: unchanged.
1980        assert_eq!(snap(0, 3), 0..3);
1981        assert_eq!(snap(3, 6), 3..6);
1982
1983        // Start inside the char floors to its first byte; end inside it ceils
1984        // to its last, so the highlight covers the whole character.
1985        assert_eq!(snap(4, 9), 3..9);
1986        assert_eq!(snap(5, 9), 3..9);
1987        assert_eq!(snap(0, 4), 0..6);
1988        assert_eq!(snap(0, 5), 0..6);
1989        assert_eq!(snap(4, 5), 3..6);
1990
1991        // Past EOF clamps to the file length.
1992        assert_eq!(snap(3, 999), 3..9);
1993        assert_eq!(snap(999, 999), 9..9);
1994
1995        // Inverted input collapses to an empty range rather than inverting.
1996        assert_eq!(snap(6, 3), 6..6);
1997
1998        // An empty range inside a character still snaps to a boundary.
1999        let r = snap(4, 4);
2000        assert!(content.is_char_boundary(r.start) && content.is_char_boundary(r.end));
2001        assert!(r.start <= r.end);
2002
2003        // Second content block: the exact offset pair the two
2004        // `..._does_not_panic` integration tests below use, now that the
2005        // `quarto-source-map` 0.1.2+ floor makes 21 unreachable at their
2006        // level (see those tests' doc comments). This is where that
2007        // coverage now lives.
2008        //
2009        // Layout (byte offsets):
2010        //   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
2011        let content2 = "text: <span>Ask AI \u{2728}</span>";
2012        assert!(!content2.is_char_boundary(21), "test fixture precondition");
2013        let snap2 = |s, e| DiagnosticMessage::snap_span_to_char_boundaries(content2, s, e);
2014
2015        // 21 is mid-`\u{2728}` (bytes 19..22) and floors to 19; 28 is
2016        // already on a boundary (inside the trailing ASCII `</span>`) and
2017        // is left unchanged.
2018        assert_eq!(snap2(21, 28), 19..28);
2019    }
2020
2021    /// A diagnostic whose `SourceInfo` span originally lands mid-character
2022    /// still renders end to end under the ariadne renderer.
2023    ///
2024    /// This test used to be the integration-level proof that
2025    /// `snap_span_to_char_boundaries` prevents ariadne's mid-character
2026    /// panic. It no longer is: `quarto-source-map` 0.1.2+ floors
2027    /// `offset_to_location`'s returned offset to a UTF-8 character
2028    /// boundary, so by the time `map_offset` hands this test's span
2029    /// (21..28, with 21 mid-`\u{2728}`) to the renderer it has already
2030    /// become 19..28 — the snap in this crate is never exercised against a
2031    /// mid-character offset at this level, because one can no longer be
2032    /// constructed here. The snap's actual coverage moved to
2033    /// `snap_span_widens_to_whole_characters`'s second content block, which
2034    /// calls it directly with these same offsets.
2035    ///
2036    /// **Accepted, not an oversight:** after the upstream floor, no revert
2037    /// of this crate's own code (the snap helper or its three call sites)
2038    /// can turn this test red — it is unbound with respect to this crate's
2039    /// diff. That is known and accepted; the test stays as an end-to-end
2040    /// smoke check that a span-carrying diagnostic renders successfully
2041    /// under ariadne, not as a snap regression test.
2042    ///
2043    /// Layout of the source below (byte offsets):
2044    ///   `text: <span>Ask AI ` = 0..19, `\u{2728}` = 19..22, `</span>` = 22..29
2045    /// so 21 is two bytes into the three-byte char — exactly the observed
2046    /// off-by-one-left onto a multi-byte boundary.
2047    #[cfg(feature = "ariadne")]
2048    #[test]
2049    fn ariadne_renders_diagnostic_with_originally_mid_character_span() {
2050        use crate::builder::DiagnosticMessageBuilder;
2051
2052        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
2053        assert!(!content.is_char_boundary(21), "test fixture precondition");
2054
2055        let mut ctx = quarto_source_map::SourceContext::new();
2056        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
2057        // 21..28 — start is mid-`\u{2728}`, mirroring the config-path shift.
2058        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
2059        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
2060            .with_code("Q-2-9")
2061            .with_location(location)
2062            .build();
2063
2064        let opts = TextRenderOptions::default().hyperlinks(false);
2065        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2066
2067        assert!(
2068            text.contains("HTML element converted to raw HTML"),
2069            "diagnostic must still render; got: {text:?}"
2070        );
2071        assert!(
2072            text.contains("_quarto.yml"),
2073            "source context must still render; got: {text:?}"
2074        );
2075    }
2076
2077    /// The same end-to-end smoke check as
2078    /// `ariadne_renders_diagnostic_with_originally_mid_character_span`, for
2079    /// the annotate-snippets renderer.
2080    ///
2081    /// It no longer exercises the mid-character path either, for the same
2082    /// reason: `quarto-source-map` 0.1.2+'s floor in `offset_to_location`
2083    /// means `map_offset` has already snapped this test's 21..28 span to
2084    /// 19..28 before it reaches annotate-snippets' `clamp` closure, so the
2085    /// closure never sees a mid-character offset from this call path. The
2086    /// snap's real coverage lives in `snap_span_widens_to_whole_characters`'s
2087    /// second content block (same 21..28 offsets, exercised directly).
2088    ///
2089    /// **Accepted, not an oversight:** as with the ariadne test above, no
2090    /// revert of this crate's own snap logic can turn this test red after
2091    /// the upstream floor — that unbinding is known and accepted.
2092    #[cfg(feature = "annotate-snippets")]
2093    #[test]
2094    fn annotate_snippets_renders_diagnostic_with_originally_mid_character_span() {
2095        use crate::builder::DiagnosticMessageBuilder;
2096
2097        let content = "text: <span>Ask AI \u{2728}</span>".to_string();
2098        assert!(!content.is_char_boundary(21), "test fixture precondition");
2099
2100        let mut ctx = quarto_source_map::SourceContext::new();
2101        let file_id = ctx.add_file("_quarto.yml".to_string(), Some(content.clone()));
2102        let location = quarto_source_map::SourceInfo::original(file_id, 21, 28);
2103        let msg = DiagnosticMessageBuilder::warning("HTML element converted to raw HTML")
2104            .with_code("Q-2-9")
2105            .with_location(location)
2106            .build();
2107
2108        let opts = TextRenderOptions::default().hyperlinks(false);
2109        let text =
2110            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2111
2112        assert!(
2113            text.contains("HTML element converted to raw HTML"),
2114            "diagnostic must still render; got: {text:?}"
2115        );
2116    }
2117
2118    /// Strip CSI SGR color sequences, for tests gated under a single
2119    /// renderer feature that can't rely on `strip_ansi` above (which is
2120    /// gated on `annotate-snippets` only — ariadne colorizes its source
2121    /// line and marker row too, so an ariadne-only test needs the same
2122    /// stripping without pulling in that feature). Same logic as
2123    /// `strip_ansi`, duplicated rather than re-gated so as not to touch
2124    /// the existing helper.
2125    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2126    fn strip_ansi_colors(s: &str) -> String {
2127        let mut out = String::new();
2128        let mut chars = s.chars().peekable();
2129        while let Some(c) = chars.next() {
2130            if c == '\u{1b}' {
2131                for n in chars.by_ref() {
2132                    if n == 'm' {
2133                        break;
2134                    }
2135                }
2136            } else {
2137                out.push(c);
2138            }
2139        }
2140        out
2141    }
2142
2143    /// Measures the *rendered* width of a label whose mapped span is
2144    /// genuinely zero-width, under the ariadne renderer.
2145    ///
2146    /// `\u{2728}` occupies bytes 6..9 of the content below; the input span
2147    /// `SourceInfo::original(fid, 7, 8)` has **both ends** strictly inside
2148    /// that character (unlike the `..._does_not_panic` tests above, whose
2149    /// span only *starts* mid-character). Before the `quarto-source-map`
2150    /// 0.1.2+ floor, `map_offset` passed the raw offsets 7 and 8 through
2151    /// unchanged and this crate's own snap widened them to the whole
2152    /// character (6..9). After the floor, `offset_to_location` already
2153    /// floors both 7 and 8 down to 6 before this crate ever sees them, so
2154    /// both mapped offsets are 6 — the snap runs on `6..6`, which is
2155    /// already boundary-aligned, and has nothing left to widen. The
2156    /// highlight that reaches the renderer is therefore zero-width, not
2157    /// the whole character.
2158    ///
2159    /// ariadne's `Report::build` anchor is already `start..start`, so "a
2160    /// zero-width label probably renders fine" was a reasonable guess
2161    /// before this test — turning that guess into a measurement is the
2162    /// point here. The assertion is keyed on the renderer's own
2163    /// zero-width-vs-one-character marker *shape* (a bare `│` vs. `┬─`),
2164    /// not merely on the message text appearing, so it fails if the
2165    /// highlight ever widens back to covering the whole character.
2166    #[cfg(feature = "ariadne")]
2167    #[test]
2168    fn ariadne_zero_width_label_renders_a_bare_marker() {
2169        use crate::builder::DiagnosticMessageBuilder;
2170
2171        let content = "x = 'A\u{2728}B'".to_string();
2172        let mut ctx = quarto_source_map::SourceContext::new();
2173        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
2174        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
2175        let msg = DiagnosticMessageBuilder::warning("scratch")
2176            .with_code("Q-2-9")
2177            .with_location(location)
2178            .build();
2179        let opts = TextRenderOptions::default().hyperlinks(false);
2180        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2181        let stripped = strip_ansi_colors(&text);
2182
2183        let lines: Vec<&str> = stripped.lines().collect();
2184        let source_idx = lines
2185            .iter()
2186            .position(|l| l.contains("x = 'A\u{2728}B'"))
2187            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
2188        let marker_line = lines[source_idx + 1];
2189        // Skip past the gutter's own `│` (present on every row, e.g.
2190        // `   │       │  `) to isolate the marker glyphs themselves.
2191        let gutter_end = marker_line
2192            .find('│')
2193            .map(|i| i + '│'.len_utf8())
2194            .unwrap_or_else(|| panic!("marker row must have a gutter `│`; got: {marker_line:?}"));
2195        let marker = marker_line[gutter_end..].trim();
2196
2197        assert_eq!(
2198            marker, "│",
2199            "expected the zero-width `│` marker (a whole-character label \
2200             would instead draw `┬─`); got {marker:?} in:\n{stripped}"
2201        );
2202    }
2203
2204    /// The same measurement as `ariadne_zero_width_label_renders_a_bare_marker`,
2205    /// for the annotate-snippets renderer. See that test's doc comment for
2206    /// why both ends of `SourceInfo::original(fid, 7, 8)` land on the same
2207    /// mapped offset (6) after the `quarto-source-map` 0.1.2+ floor.
2208    ///
2209    /// annotate-snippets underlines a span with one `^` per byte of width,
2210    /// so the discriminating measurement here is even more direct than
2211    /// ariadne's marker shape: a zero-width label draws exactly one `^`,
2212    /// a whole-character label draws two (`^^`).
2213    #[cfg(feature = "annotate-snippets")]
2214    #[test]
2215    fn annotate_snippets_zero_width_label_renders_a_single_caret() {
2216        use crate::builder::DiagnosticMessageBuilder;
2217
2218        let content = "x = 'A\u{2728}B'".to_string();
2219        let mut ctx = quarto_source_map::SourceContext::new();
2220        let file_id = ctx.add_file("scratch.qmd".to_string(), Some(content.clone()));
2221        let location = quarto_source_map::SourceInfo::original(file_id, 7, 8);
2222        let msg = DiagnosticMessageBuilder::warning("scratch")
2223            .with_code("Q-2-9")
2224            .with_location(location)
2225            .build();
2226        let opts = TextRenderOptions::default().hyperlinks(false);
2227        let text =
2228            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2229        let stripped = strip_ansi_colors(&text);
2230
2231        let lines: Vec<&str> = stripped.lines().collect();
2232        let source_idx = lines
2233            .iter()
2234            .position(|l| l.contains("x = 'A\u{2728}B'"))
2235            .unwrap_or_else(|| panic!("source line must render; got: {stripped:?}"));
2236        let marker_line = lines[source_idx + 1];
2237        let caret_run: String = marker_line.chars().filter(|&c| c == '^').collect();
2238
2239        assert_eq!(
2240            caret_run, "^",
2241            "expected a single `^` caret marking a zero-width label (a \
2242             whole-character label would instead draw `^^`); got \
2243             {caret_run:?} in line: {marker_line:?}"
2244        );
2245    }
2246
2247    /// Forcing a specific renderer is honored: ariadne draws its boxed
2248    /// excerpt (the U+256D corner) while annotate-snippets does not.
2249    #[cfg(all(feature = "ariadne", feature = "annotate-snippets"))]
2250    #[test]
2251    fn renderer_selection_switches_styles() {
2252        use crate::builder::DiagnosticMessageBuilder;
2253
2254        let mut ctx = quarto_source_map::SourceContext::new();
2255        let file_id = ctx.add_file("a.qmd".to_string(), Some("alpha\nbeta\ngamma".to_string()));
2256        let location = quarto_source_map::SourceInfo::original(file_id, 6, 10); // "beta"
2257        let msg = DiagnosticMessageBuilder::error("Pick a style")
2258            .with_location(location)
2259            .build();
2260        let opts = TextRenderOptions::default().hyperlinks(false);
2261
2262        let ariadne = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2263        let snippets =
2264            msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2265
2266        assert!(ariadne.contains('\u{256D}'), "ariadne draws a box corner");
2267        assert!(
2268            !strip_ansi(&snippets).contains('\u{256D}'),
2269            "annotate-snippets does not"
2270        );
2271        assert!(strip_ansi(&snippets).contains("-->"));
2272    }
2273
2274    // ==================== Concat / cross-piece rendering ====================
2275    //
2276    // A diagnostic whose location resolves through a multi-piece `Concat`
2277    // (the shape q2's ipynb processor produces: one virtual file per
2278    // notebook cell) must render against the piece the span actually
2279    // resolves into — not `root_file_id()`, which is the *first* rooted
2280    // piece. These tests pin the plan-7c contract: report file from
2281    // `start_mapped.file_id`; a span straddling pieces renders a
2282    // cross-file location label with no snippet; a detail rooted in
2283    // another piece renders in its own file's source block.
2284
2285    /// Two per-cell virtual files joined by a `Concat`, exactly as the
2286    /// ipynb converter registers them. Returns the context, the concat,
2287    /// and piece 1's length (the concat offset where piece 2 begins).
2288    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2289    fn concat_fixture() -> (
2290        quarto_source_map::SourceContext,
2291        quarto_source_map::SourceInfo,
2292        usize,
2293    ) {
2294        let piece1 = "first cell text\n";
2295        let piece2 = "second cell text\n";
2296        let mut ctx = quarto_source_map::SourceContext::new();
2297        let f1 = ctx.add_file(
2298            "notebook.ipynb[cell 1, markdown]".to_string(),
2299            Some(piece1.to_string()),
2300        );
2301        let f2 = ctx.add_file(
2302            "notebook.ipynb[cell 2, markdown]".to_string(),
2303            Some(piece2.to_string()),
2304        );
2305        let concat = quarto_source_map::SourceInfo::concat(vec![
2306            (
2307                quarto_source_map::SourceInfo::original(f1, 0, piece1.len()),
2308                piece1.len(),
2309            ),
2310            (
2311                quarto_source_map::SourceInfo::original(f2, 0, piece2.len()),
2312                piece2.len(),
2313            ),
2314        ]);
2315        (ctx, concat, piece1.len())
2316    }
2317
2318    /// The percent/spin shape: a `Concat` whose pieces all root to ONE
2319    /// file. Must render exactly as a plain single-file diagnostic — the
2320    /// fix must not disturb it.
2321    #[cfg(feature = "ariadne")]
2322    #[test]
2323    fn ariadne_concat_single_file_pieces_passthrough() {
2324        use crate::builder::DiagnosticMessageBuilder;
2325
2326        let content = "alpha\nbeta\ngamma\n";
2327        let mut ctx = quarto_source_map::SourceContext::new();
2328        let f1 = ctx.add_file("script.qmd".to_string(), Some(content.to_string()));
2329        let concat = quarto_source_map::SourceInfo::concat(vec![
2330            (quarto_source_map::SourceInfo::original(f1, 0, 6), 6),
2331            (
2332                quarto_source_map::SourceInfo::original(f1, 6, content.len()),
2333                content.len() - 6,
2334            ),
2335        ]);
2336        let location = quarto_source_map::SourceInfo::substring(concat, 6, 10); // "beta"
2337        let msg = DiagnosticMessageBuilder::error("Bad chunk")
2338            .with_location(location)
2339            .build();
2340        let opts = TextRenderOptions::default().hyperlinks(false);
2341        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2342            Some(&ctx),
2343            &opts,
2344            Some(SourceRenderer::Ariadne),
2345        ));
2346
2347        assert!(
2348            text.contains("script.qmd"),
2349            "single-file passthrough must keep the file label; got:\n{text}"
2350        );
2351        assert!(
2352            text.contains("beta"),
2353            "single-file passthrough must render the snippet; got:\n{text}"
2354        );
2355    }
2356
2357    /// A main span rooted wholly in the *first* piece already renders
2358    /// correctly (`root_file_id()` happens to agree with
2359    /// `start_mapped.file_id`); must stay correct after the fix.
2360    #[cfg(feature = "ariadne")]
2361    #[test]
2362    fn ariadne_concat_main_span_in_first_piece_renders_own_file() {
2363        use crate::builder::DiagnosticMessageBuilder;
2364
2365        let (ctx, concat, _) = concat_fixture();
2366        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5); // "first"
2367        let msg = DiagnosticMessageBuilder::error("Bad cell")
2368            .with_location(location)
2369            .build();
2370        let opts = TextRenderOptions::default().hyperlinks(false);
2371        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2372            Some(&ctx),
2373            &opts,
2374            Some(SourceRenderer::Ariadne),
2375        ));
2376
2377        assert!(
2378            text.contains("notebook.ipynb[cell 1, markdown]"),
2379            "first-piece span must label piece 1; got:\n{text}"
2380        );
2381        assert!(
2382            text.contains("first cell text"),
2383            "first-piece span must show piece 1's snippet; got:\n{text}"
2384        );
2385        assert!(
2386            !text.contains("notebook.ipynb[cell 2, markdown]"),
2387            "piece 2 must not appear; got:\n{text}"
2388        );
2389    }
2390
2391    /// THE cross-piece case: a diagnostic rooted wholly in a later piece
2392    /// must be labeled and snippeted from *that* piece — today it gets
2393    /// piece 1's label and piece 1's content at piece-2 offsets.
2394    #[cfg(feature = "ariadne")]
2395    #[test]
2396    fn ariadne_concat_main_span_in_later_piece_renders_own_file() {
2397        use crate::builder::DiagnosticMessageBuilder;
2398
2399        let (ctx, concat, l1) = concat_fixture();
2400        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2401        let msg = DiagnosticMessageBuilder::error("Bad cell")
2402            .with_location(location)
2403            .build();
2404        let opts = TextRenderOptions::default().hyperlinks(false);
2405        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2406            Some(&ctx),
2407            &opts,
2408            Some(SourceRenderer::Ariadne),
2409        ));
2410
2411        assert!(
2412            text.contains("notebook.ipynb[cell 2, markdown]"),
2413            "later-piece span must label the owning piece; got:\n{text}"
2414        );
2415        assert!(
2416            text.contains("second cell text"),
2417            "later-piece span must snippet the owning piece; got:\n{text}"
2418        );
2419        assert!(
2420            !text.contains("first cell text"),
2421            "the wrong piece must not render; got:\n{text}"
2422        );
2423    }
2424
2425    /// A span straddling two pieces cannot be one snippet; it must render
2426    /// an explicit cross-file label naming both pieces and no snippet —
2427    /// today it is silently clamped into piece 1.
2428    #[cfg(feature = "ariadne")]
2429    #[test]
2430    fn ariadne_concat_straddling_span_labels_both_pieces_without_snippet() {
2431        use crate::builder::DiagnosticMessageBuilder;
2432
2433        let (ctx, concat, l1) = concat_fixture();
2434        // Starts mid-piece-1 ("cell text…"), ends mid-piece-2 ("second").
2435        let location = quarto_source_map::SourceInfo::substring(concat, 6, l1 + 6);
2436        let msg = DiagnosticMessageBuilder::error("Cross-cell markdown")
2437            .with_location(location)
2438            .build();
2439        let opts = TextRenderOptions::default().hyperlinks(false);
2440        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2441            Some(&ctx),
2442            &opts,
2443            Some(SourceRenderer::Ariadne),
2444        ));
2445
2446        assert!(
2447            text.contains("--> notebook.ipynb[cell 1, markdown]:1:7"),
2448            "straddling span must name its start piece and position; got:\n{text}"
2449        );
2450        assert!(
2451            text.contains("(spans through notebook.ipynb[cell 2, markdown]:1:7)"),
2452            "straddling span must name its end piece and position; got:\n{text}"
2453        );
2454        assert!(
2455            !text.contains("first cell text") && !text.contains("second cell text"),
2456            "a cross-piece span must render no snippet; got:\n{text}"
2457        );
2458    }
2459
2460    /// A detail rooted wholly in another piece must render — in its own
2461    /// file's source block — not be silently dropped.
2462    #[cfg(feature = "ariadne")]
2463    #[test]
2464    fn ariadne_concat_detail_in_another_piece_renders_own_file() {
2465        use crate::builder::DiagnosticMessageBuilder;
2466
2467        let (ctx, concat, l1) = concat_fixture();
2468        let main = quarto_source_map::SourceInfo::substring(concat.clone(), 0, 5);
2469        let detail = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6);
2470        let msg = DiagnosticMessageBuilder::error("Mismatch")
2471            .with_location(main)
2472            .add_detail_at("related token in cell 2", detail)
2473            .build();
2474        let opts = TextRenderOptions::default().hyperlinks(false);
2475        let text = strip_ansi_colors(&msg.to_text_with_renderer(
2476            Some(&ctx),
2477            &opts,
2478            Some(SourceRenderer::Ariadne),
2479        ));
2480
2481        assert!(
2482            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2483            "main span must still render; got:\n{text}"
2484        );
2485        assert!(
2486            text.contains("notebook.ipynb[cell 2, markdown]"),
2487            "foreign-piece detail must render its own block; got:\n{text}"
2488        );
2489        assert!(
2490            text.contains("second cell text"),
2491            "foreign-piece detail must snippet its own file; got:\n{text}"
2492        );
2493        assert!(
2494            text.contains("related token in cell 2"),
2495            "foreign-piece detail must keep its message; got:\n{text}"
2496        );
2497    }
2498
2499    /// Passthrough for the annotate-snippets renderer (see the ariadne
2500    /// twin for the contract).
2501    #[cfg(feature = "annotate-snippets")]
2502    #[test]
2503    fn annotate_snippets_concat_single_file_pieces_passthrough() {
2504        use crate::builder::DiagnosticMessageBuilder;
2505
2506        let content = "alpha\nbeta\ngamma\n";
2507        let mut ctx = quarto_source_map::SourceContext::new();
2508        let f1 = ctx.add_file("script.qmd".to_string(), Some(content.to_string()));
2509        let concat = quarto_source_map::SourceInfo::concat(vec![
2510            (quarto_source_map::SourceInfo::original(f1, 0, 6), 6),
2511            (
2512                quarto_source_map::SourceInfo::original(f1, 6, content.len()),
2513                content.len() - 6,
2514            ),
2515        ]);
2516        let location = quarto_source_map::SourceInfo::substring(concat, 6, 10); // "beta"
2517        let msg = DiagnosticMessageBuilder::error("Bad chunk")
2518            .with_location(location)
2519            .build();
2520        let opts = TextRenderOptions::default().hyperlinks(false);
2521        let text = strip_ansi(&msg.to_text_with_renderer(
2522            Some(&ctx),
2523            &opts,
2524            Some(SourceRenderer::AnnotateSnippets),
2525        ));
2526
2527        assert!(
2528            text.contains("script.qmd") && text.contains("beta"),
2529            "single-file passthrough must render unchanged; got:\n{text}"
2530        );
2531    }
2532
2533    /// First-piece span under annotate-snippets (see the ariadne twin).
2534    #[cfg(feature = "annotate-snippets")]
2535    #[test]
2536    fn annotate_snippets_concat_main_span_in_first_piece_renders_own_file() {
2537        use crate::builder::DiagnosticMessageBuilder;
2538
2539        let (ctx, concat, _) = concat_fixture();
2540        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5); // "first"
2541        let msg = DiagnosticMessageBuilder::error("Bad cell")
2542            .with_location(location)
2543            .build();
2544        let opts = TextRenderOptions::default().hyperlinks(false);
2545        let text = strip_ansi(&msg.to_text_with_renderer(
2546            Some(&ctx),
2547            &opts,
2548            Some(SourceRenderer::AnnotateSnippets),
2549        ));
2550
2551        assert!(
2552            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2553            "first-piece span must render piece 1; got:\n{text}"
2554        );
2555        assert!(
2556            !text.contains("notebook.ipynb[cell 2, markdown]"),
2557            "piece 2 must not appear; got:\n{text}"
2558        );
2559    }
2560
2561    /// Later-piece span under annotate-snippets (see the ariadne twin).
2562    #[cfg(feature = "annotate-snippets")]
2563    #[test]
2564    fn annotate_snippets_concat_main_span_in_later_piece_renders_own_file() {
2565        use crate::builder::DiagnosticMessageBuilder;
2566
2567        let (ctx, concat, l1) = concat_fixture();
2568        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2569        let msg = DiagnosticMessageBuilder::error("Bad cell")
2570            .with_location(location)
2571            .build();
2572        let opts = TextRenderOptions::default().hyperlinks(false);
2573        let text = strip_ansi(&msg.to_text_with_renderer(
2574            Some(&ctx),
2575            &opts,
2576            Some(SourceRenderer::AnnotateSnippets),
2577        ));
2578
2579        assert!(
2580            text.contains("notebook.ipynb[cell 2, markdown]"),
2581            "later-piece span must label the owning piece; got:\n{text}"
2582        );
2583        assert!(
2584            text.contains("second cell text"),
2585            "later-piece span must snippet the owning piece; got:\n{text}"
2586        );
2587        assert!(
2588            !text.contains("first cell text"),
2589            "the wrong piece must not render; got:\n{text}"
2590        );
2591    }
2592
2593    /// Straddling span under annotate-snippets (see the ariadne twin).
2594    #[cfg(feature = "annotate-snippets")]
2595    #[test]
2596    fn annotate_snippets_concat_straddling_span_labels_both_pieces_without_snippet() {
2597        use crate::builder::DiagnosticMessageBuilder;
2598
2599        let (ctx, concat, l1) = concat_fixture();
2600        let location = quarto_source_map::SourceInfo::substring(concat, 6, l1 + 6);
2601        let msg = DiagnosticMessageBuilder::error("Cross-cell markdown")
2602            .with_location(location)
2603            .build();
2604        let opts = TextRenderOptions::default().hyperlinks(false);
2605        let text = strip_ansi(&msg.to_text_with_renderer(
2606            Some(&ctx),
2607            &opts,
2608            Some(SourceRenderer::AnnotateSnippets),
2609        ));
2610
2611        assert!(
2612            text.contains("--> notebook.ipynb[cell 1, markdown]:1:7"),
2613            "straddling span must name its start piece and position; got:\n{text}"
2614        );
2615        assert!(
2616            text.contains("(spans through notebook.ipynb[cell 2, markdown]:1:7)"),
2617            "straddling span must name its end piece and position; got:\n{text}"
2618        );
2619        assert!(
2620            !text.contains("first cell text") && !text.contains("second cell text"),
2621            "a cross-piece span must render no snippet; got:\n{text}"
2622        );
2623    }
2624
2625    /// Foreign-piece detail under annotate-snippets (see the ariadne
2626    /// twin).
2627    #[cfg(feature = "annotate-snippets")]
2628    #[test]
2629    fn annotate_snippets_concat_detail_in_another_piece_renders_own_file() {
2630        use crate::builder::DiagnosticMessageBuilder;
2631
2632        let (ctx, concat, l1) = concat_fixture();
2633        let main = quarto_source_map::SourceInfo::substring(concat.clone(), 0, 5);
2634        let detail = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6);
2635        let msg = DiagnosticMessageBuilder::error("Mismatch")
2636            .with_location(main)
2637            .add_detail_at("related token in cell 2", detail)
2638            .build();
2639        let opts = TextRenderOptions::default().hyperlinks(false);
2640        let text = strip_ansi(&msg.to_text_with_renderer(
2641            Some(&ctx),
2642            &opts,
2643            Some(SourceRenderer::AnnotateSnippets),
2644        ));
2645
2646        assert!(
2647            text.contains("notebook.ipynb[cell 1, markdown]") && text.contains("first cell text"),
2648            "main span must still render; got:\n{text}"
2649        );
2650        assert!(
2651            text.contains("notebook.ipynb[cell 2, markdown]")
2652                && text.contains("second cell text")
2653                && text.contains("related token in cell 2"),
2654            "foreign-piece detail must render its own block with its message; got:\n{text}"
2655        );
2656    }
2657
2658    // ==================== Origin-aware hyperlinks (plan 7c) ====================
2659    //
2660    // A virtual file carrying `FileMetadata::origin` (a notebook cell) is
2661    // hyperlinked to the *owning notebook on disk*, and the `#line:column`
2662    // fragment must be suppressed: origin coordinates are cell-relative and
2663    // would be wrong in the notebook's URL. Without origin, a virtual file
2664    // gets no hyperlink at all (its pseudo-path does not exist on disk).
2665
2666    /// Extract every OSC 8 link URL from raw terminal output.
2667    #[cfg(feature = "ariadne")]
2668    fn osc8_urls(text: &str) -> Vec<String> {
2669        let mut urls = Vec::new();
2670        let mut rest = text;
2671        while let Some(start) = rest.find("\x1b]8;;") {
2672            let after = &rest[start + 5..];
2673            match after.find("\x1b\\") {
2674                Some(end) => {
2675                    urls.push(after[..end].to_string());
2676                    rest = &after[end..];
2677                }
2678                None => break,
2679            }
2680        }
2681        urls
2682    }
2683
2684    /// A real notebook on disk plus two per-cell virtual files joined by a
2685    /// `Concat`, with `origin` attached to both cells. `notebook_path` is the
2686    /// absolute disk path — what q2 registers after resolving the document.
2687    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2688    fn origin_fixture() -> (
2689        quarto_source_map::SourceContext,
2690        quarto_source_map::SourceInfo,
2691        tempfile::TempDir,
2692        std::path::PathBuf,
2693        usize,
2694    ) {
2695        let piece1 = "first cell text\n";
2696        let piece2 = "second cell text\n";
2697        let dir = tempfile::TempDir::new().unwrap();
2698        let notebook = dir.path().join("notebook.ipynb");
2699        std::fs::write(&notebook, "{}").unwrap();
2700        let origin_for = |index: usize| {
2701            Some(quarto_source_map::FileOrigin::NotebookCell {
2702                notebook_path: notebook.display().to_string(),
2703                cell_index: index,
2704                cell_id: None,
2705                cell_type: "markdown".to_string(),
2706            })
2707        };
2708        let mut ctx = quarto_source_map::SourceContext::new();
2709        let f1 = ctx.add_file(
2710            "notebook.ipynb[cell 1, markdown]".to_string(),
2711            Some(piece1.to_string()),
2712        );
2713        let f2 = ctx.add_file(
2714            "notebook.ipynb[cell 2, markdown]".to_string(),
2715            Some(piece2.to_string()),
2716        );
2717        ctx.get_file_mut(f1).unwrap().metadata.origin = origin_for(1);
2718        ctx.get_file_mut(f2).unwrap().metadata.origin = origin_for(2);
2719        let concat = quarto_source_map::SourceInfo::concat(vec![
2720            (
2721                quarto_source_map::SourceInfo::original(f1, 0, piece1.len()),
2722                piece1.len(),
2723            ),
2724            (
2725                quarto_source_map::SourceInfo::original(f2, 0, piece2.len()),
2726                piece2.len(),
2727            ),
2728        ]);
2729        (ctx, concat, dir, notebook, piece1.len())
2730    }
2731
2732    #[cfg(feature = "ariadne")]
2733    #[test]
2734    fn ariadne_origin_cell_hyperlinks_the_owning_notebook() {
2735        use crate::builder::DiagnosticMessageBuilder;
2736
2737        let (ctx, concat, _dir, notebook, l1) = origin_fixture();
2738        let location = quarto_source_map::SourceInfo::substring(concat, l1, l1 + 6); // "second"
2739        let msg = DiagnosticMessageBuilder::error("Bad cell")
2740            .with_location(location)
2741            .build();
2742        let opts = TextRenderOptions::default().hyperlinks(true);
2743        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2744
2745        // Compute the expected URL exactly the way the renderer does, so
2746        // platform-specific path forms (Windows verbatim `\\?\…`) can't
2747        // make the test diverge from production.
2748        let canonical = std::fs::canonicalize(&notebook).unwrap();
2749        let expected_prefix =
2750            url::Url::from_file_path(super::DiagnosticMessage::plain_absolute_path(canonical))
2751                .unwrap()
2752                .as_str()
2753                .to_string();
2754        let urls = osc8_urls(&text);
2755        let cell_link = urls
2756            .iter()
2757            .find(|u| u.starts_with(&expected_prefix))
2758            .expect("cell label must hyperlink to the owning notebook");
2759
2760        // The fragment must be absent: origin coordinates are cell-relative
2761        // and would be wrong as a notebook line/column.
2762        assert!(
2763            !cell_link.contains('#'),
2764            "cell hyperlink must not carry a #line:col fragment; got {cell_link:?}"
2765        );
2766        // The human-readable label is still the cell-qualified pseudo-path.
2767        assert!(
2768            text.contains("notebook.ipynb[cell 2, markdown]"),
2769            "label must stay the pseudo-path; got:\n{text}"
2770        );
2771    }
2772
2773    #[cfg(feature = "ariadne")]
2774    #[test]
2775    fn ariadne_virtual_file_without_origin_gets_no_hyperlink() {
2776        use crate::builder::DiagnosticMessageBuilder;
2777
2778        let (ctx, concat, _l1) = concat_fixture();
2779        let location = quarto_source_map::SourceInfo::substring(concat, 0, 5);
2780        let msg = DiagnosticMessageBuilder::error("Bad cell")
2781            .with_location(location)
2782            .build();
2783        let opts = TextRenderOptions::default().hyperlinks(true);
2784        let text = msg.to_text_with_renderer(Some(&ctx), &opts, Some(SourceRenderer::Ariadne));
2785
2786        assert!(
2787            osc8_urls(&text).is_empty(),
2788            "a virtual file whose path does not exist on disk must not be hyperlinked; got:\n{text}"
2789        );
2790    }
2791
2792    // ==================== Plain rendering (qe-hal9cc7b) ====================
2793    //
2794    // `TextRenderOptions::plain()` must produce text with no escape byte at
2795    // all — no SGR color, no OSC 8 — from either renderer, including for
2796    // files that *would* be hyperlinked. Assertions are on the raw `String`
2797    // (a `\x1b` byte), never on serialized JSON, where escapes appear as
2798    // `\u001b` and a byte search cannot see them.
2799
2800    /// The renderers compiled into this build.
2801    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2802    fn available_renderers() -> Vec<SourceRenderer> {
2803        vec![
2804            #[cfg(feature = "ariadne")]
2805            SourceRenderer::Ariadne,
2806            #[cfg(feature = "annotate-snippets")]
2807            SourceRenderer::AnnotateSnippets,
2808        ]
2809    }
2810
2811    /// One located diagnostic per rendering shape that emits color or
2812    /// hyperlinks: every detail kind (including the faded mirror color),
2813    /// a real on-disk file (self-hyperlinked), a notebook cell with a
2814    /// detail in another piece (origin-hyperlinked foreign section), and a
2815    /// cross-piece span. Each entry carries a source excerpt that proves
2816    /// the snippet renderer actually ran. The `TempDir`s keep the on-disk
2817    /// files alive for the caller.
2818    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2819    #[allow(clippy::type_complexity)]
2820    fn escape_fixtures() -> (
2821        Vec<(
2822            &'static str,
2823            quarto_source_map::SourceContext,
2824            DiagnosticMessage,
2825            &'static str,
2826        )>,
2827        Vec<tempfile::TempDir>,
2828    ) {
2829        use crate::builder::DiagnosticMessageBuilder;
2830        use quarto_source_map::SourceInfo;
2831
2832        let mut fixtures = Vec::new();
2833        let mut dirs = Vec::new();
2834
2835        // Ephemeral file; every detail kind, plus unlocated detail and hint.
2836        let content = "> quoted one\n> quoted two\nplain line\n";
2837        let mut ctx = quarto_source_map::SourceContext::new();
2838        let f = ctx.add_file("test.qmd".to_string(), Some(content.to_string()));
2839        let msg = DiagnosticMessageBuilder::error("Every detail kind")
2840            .with_code("Q-9-9")
2841            .with_location(SourceInfo::original(f, 0, 25))
2842            .problem("the span is wrong")
2843            .add_detail_at("error detail", SourceInfo::original(f, 2, 8))
2844            .add_info_at("info detail", SourceInfo::original(f, 26, 31))
2845            .add_note_at("note detail", SourceInfo::original(f, 15, 21))
2846            .add_faded_at("", SourceInfo::original(f, 13, 15))
2847            .add_detail("unlocated detail")
2848            .add_hint("a hint")
2849            .build();
2850        fixtures.push(("detail_kinds", ctx, msg, "quoted two"));
2851
2852        // Real file on disk: ariadne hyperlinks it under default options.
2853        let dir = tempfile::TempDir::new().unwrap();
2854        let path = dir.path().join("real.qmd");
2855        std::fs::write(&path, "title: [oops\n").unwrap();
2856        let mut ctx = quarto_source_map::SourceContext::new();
2857        let f = ctx.add_file(path.display().to_string(), None);
2858        let msg = DiagnosticMessageBuilder::warning("Real file")
2859            .with_location(SourceInfo::original(f, 7, 12))
2860            .build();
2861        fixtures.push(("real_file", ctx, msg, "title: [oops"));
2862        dirs.push(dir);
2863
2864        // Notebook cells with origin; a detail rooted in the other cell
2865        // renders as its own (hyperlinked, colored) source section.
2866        let (ctx, concat, dir, _notebook, l1) = origin_fixture();
2867        let msg = DiagnosticMessageBuilder::error("Bad cell")
2868            .with_location(SourceInfo::substring(concat.clone(), 0, 5))
2869            .add_detail_at("in cell 2", SourceInfo::substring(concat, l1, l1 + 6))
2870            .build();
2871        fixtures.push(("notebook_origin", ctx, msg, "second cell text"));
2872        dirs.push(dir);
2873
2874        // A span straddling two pieces renders as a textual location.
2875        let (ctx, concat, l1) = concat_fixture();
2876        let msg = DiagnosticMessageBuilder::error("Straddles")
2877            .with_location(SourceInfo::substring(concat, l1 - 5, l1 + 6))
2878            .build();
2879        fixtures.push(("cross_piece", ctx, msg, "cell 2"));
2880
2881        (fixtures, dirs)
2882    }
2883
2884    #[test]
2885    fn plain_constructor_disables_color_and_hyperlinks() {
2886        let plain = TextRenderOptions::plain();
2887        assert!(!plain.enable_color && !plain.enable_hyperlinks);
2888        let default = TextRenderOptions::default();
2889        assert!(default.enable_color && default.enable_hyperlinks);
2890        let mixed = TextRenderOptions::plain().hyperlinks(true);
2891        assert!(!mixed.enable_color && mixed.enable_hyperlinks);
2892        let mixed = TextRenderOptions::default().color(false);
2893        assert!(!mixed.enable_color && mixed.enable_hyperlinks);
2894    }
2895
2896    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2897    #[test]
2898    fn plain_rendering_contains_no_escape_bytes() {
2899        let (fixtures, _dirs) = escape_fixtures();
2900        for renderer in available_renderers() {
2901            for (name, ctx, msg, excerpt) in &fixtures {
2902                let text = msg.to_text_with_renderer(
2903                    Some(ctx),
2904                    &TextRenderOptions::plain(),
2905                    Some(renderer),
2906                );
2907                assert!(
2908                    !text.contains('\u{1b}'),
2909                    "{renderer:?}/{name}: plain output must contain no escape byte; got:\n{text:?}"
2910                );
2911                assert!(
2912                    text.contains(excerpt),
2913                    "{renderer:?}/{name}: the snippet renderer must have run; got:\n{text}"
2914                );
2915            }
2916        }
2917    }
2918
2919    /// Plain is the same layout as the colored rendering, minus the color:
2920    /// stripping SGR codes from the colored text yields the plain text
2921    /// byte for byte. Hyperlinks are off on both sides (they're a separate
2922    /// knob, tested below).
2923    #[cfg(any(feature = "ariadne", feature = "annotate-snippets"))]
2924    #[test]
2925    fn plain_rendering_matches_colored_rendering_stripped() {
2926        let (fixtures, _dirs) = escape_fixtures();
2927        let colored_opts = TextRenderOptions::default().hyperlinks(false);
2928        for renderer in available_renderers() {
2929            for (name, ctx, msg, _) in &fixtures {
2930                let colored = msg.to_text_with_renderer(Some(ctx), &colored_opts, Some(renderer));
2931                let plain = msg.to_text_with_renderer(
2932                    Some(ctx),
2933                    &TextRenderOptions::plain(),
2934                    Some(renderer),
2935                );
2936                assert_eq!(
2937                    strip_ansi_colors(&colored),
2938                    plain,
2939                    "{renderer:?}/{name}: plain must equal the colored rendering minus SGR codes"
2940                );
2941            }
2942        }
2943    }
2944
2945    /// The default (terminal) rendering is unchanged: it still carries SGR
2946    /// color and, for a hyperlinkable file, OSC 8.
2947    #[cfg(feature = "ariadne")]
2948    #[test]
2949    fn default_rendering_keeps_color_and_hyperlinks() {
2950        let (fixtures, _dirs) = escape_fixtures();
2951        let (_, ctx, msg, _) = fixtures.iter().find(|f| f.0 == "real_file").unwrap();
2952        let text = msg.to_text_with_renderer(
2953            Some(ctx),
2954            &TextRenderOptions::default(),
2955            Some(SourceRenderer::Ariadne),
2956        );
2957        assert!(
2958            text.contains("\u{1b}["),
2959            "default must keep SGR color: {text:?}"
2960        );
2961        assert!(
2962            text.contains("\u{1b}]8;"),
2963            "default must keep OSC 8: {text:?}"
2964        );
2965    }
2966
2967    /// Color and hyperlinks are independent: color off with hyperlinks on
2968    /// keeps OSC 8 but emits no SGR code.
2969    #[cfg(feature = "ariadne")]
2970    #[test]
2971    fn color_off_keeps_hyperlinks() {
2972        let (fixtures, _dirs) = escape_fixtures();
2973        let opts = TextRenderOptions::default().color(false);
2974        for name in ["real_file", "notebook_origin"] {
2975            let (_, ctx, msg, _) = fixtures.iter().find(|f| f.0 == name).unwrap();
2976            let text = msg.to_text_with_renderer(Some(ctx), &opts, Some(SourceRenderer::Ariadne));
2977            assert!(
2978                text.contains("\u{1b}]8;"),
2979                "{name}: hyperlinks stay on with color off: {text:?}"
2980            );
2981            assert!(
2982                !text.contains("\u{1b}["),
2983                "{name}: no SGR code with color off: {text:?}"
2984            );
2985        }
2986    }
2987
2988    /// annotate-snippets never emits OSC 8, so color off alone already
2989    /// yields escape-free text, even with hyperlinks requested.
2990    #[cfg(feature = "annotate-snippets")]
2991    #[test]
2992    fn annotate_snippets_color_off_has_no_escape_bytes() {
2993        let (fixtures, _dirs) = escape_fixtures();
2994        let opts = TextRenderOptions::default().color(false);
2995        for (name, ctx, msg, _) in &fixtures {
2996            let text =
2997                msg.to_text_with_renderer(Some(ctx), &opts, Some(SourceRenderer::AnnotateSnippets));
2998            assert!(
2999                !text.contains('\u{1b}'),
3000                "{name}: no escape byte expected; got:\n{text:?}"
3001            );
3002        }
3003    }
3004}