tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! The crate's one diagnostic renderer (#1576).
//!
//! Before this module, `annotate-snippets` was used in exactly one place
//! (`commands::lint`) and every other user-facing message was hand-rolled plain
//! text: `TixError` through `thiserror`, [`crate::error::render_diagnostics`] as
//! `kind: message | …`, the relationship/hierarchy/workflow refusals, the editor
//! round-trip labels, and the ad-hoc `note:`/`warning:` lines. A user who learned
//! one surface's format had to learn the next one from scratch. This module is the
//! single renderer they all route through.
//!
//! A [`Diagnostic`] is a **value**, not a string: it carries the severity, a stable
//! id (`E-tix-lint`, `E-tix-relationship`, …), the headline, an optional source
//! location, and `note:`/`help:` footers. Rendering is a pure function of that
//! value, so the same diagnostic prints identically from the CLI, the TUI, and
//! MCP, and the format is unit-testable without a terminal.
//!
//! ## Colour
//!
//! [`Diagnostic::render`] is **plain** — deterministic bytes, what tests, pipes,
//! and log files want. [`Diagnostic::render_styled`] adds colour and is for a
//! real terminal. Nothing in the crate decides which by sniffing `isatty`; the
//! caller picks, so a test never depends on where its output happens to go.
//!
//! ## Ids
//!
//! The id is the rustc-style bracket name (`error[E-tix-lint]`). It is **stable**
//! and machine-matchable, and it is deliberately *not* the contract's diagnostic
//! kind (`self_link`, `unmapped_column_default`, …): the kind is the wire
//! vocabulary that MCP and tests match on, the id is the rendering name, and
//! keeping them separate means neither can be renamed by accident while porting
//! the other.

use std::ops::Range;
use std::path::PathBuf;

use annotate_snippets::{AnnotationKind, Group, Level, Renderer, Snippet};

/// How much a diagnostic should worry the reader.
///
/// Mirrors [`crate::commands::lint::Severity`] deliberately: the crate already
/// decided that only errors set an exit code and that a warning is printed and
/// not fatal (`.tools`/#1361), and the distinction has to be the same everywhere
/// or the exit code and the output stop agreeing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
    /// Printed and fatal — the caller decides the exit code, but this is a
    /// problem with the data.
    Error,
    /// Printed, counted, and **not** fatal.
    Warning,
    /// Purely informational — a `note:`, not a problem.
    Note,
}

impl Severity {
    fn level(self) -> Level<'static> {
        match self {
            Severity::Error => Level::ERROR,
            Severity::Warning => Level::WARNING,
            Severity::Note => Level::NOTE,
        }
    }
}

/// Where a diagnostic points, when it can point at all.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
    /// The file the span is into.
    pub path: PathBuf,
    /// Byte range into that file's text.
    pub span: Range<usize>,
}

/// One rendered-or-renderable finding.
///
/// Build with [`Diagnostic::error`] / [`Diagnostic::warning`], then chain
/// [`Diagnostic::at`], [`Diagnostic::labelled`], [`Diagnostic::with_note`], and
/// [`Diagnostic::with_help`]. Every builder returns `Self`, so a diagnostic is
/// assembled in one expression.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic {
    severity: Severity,
    id: &'static str,
    message: String,
    location: Option<Location>,
    label: Option<String>,
    notes: Vec<String>,
    help: Option<String>,
}

impl Diagnostic {
    /// A fatal finding.
    #[must_use]
    pub fn error(id: &'static str, message: impl Into<String>) -> Self {
        Self::new(Severity::Error, id, message)
    }

    /// A non-fatal finding.
    #[must_use]
    pub fn warning(id: &'static str, message: impl Into<String>) -> Self {
        Self::new(Severity::Warning, id, message)
    }

    /// An informational note — no problem, just something worth saying.
    #[must_use]
    pub fn note(id: &'static str, message: impl Into<String>) -> Self {
        Self::new(Severity::Note, id, message)
    }

    #[must_use]
    fn new(severity: Severity, id: &'static str, message: impl Into<String>) -> Self {
        Self {
            severity,
            id,
            message: message.into(),
            location: None,
            label: None,
            notes: Vec::new(),
            help: None,
        }
    }

    /// Point the diagnostic at a byte span in `path`.
    ///
    /// The file is read at render time, not here: the diagnostic stays a plain
    /// value that can be moved across threads or compared, and a file that has
    /// gone missing degrades to a title-only block instead of panicking.
    #[must_use]
    pub fn at(mut self, path: impl Into<PathBuf>, span: Range<usize>) -> Self {
        self.location = Some(Location {
            path: path.into(),
            span,
        });
        self
    }

    /// The label underlined on the span. Defaults to the headline when unset.
    #[must_use]
    pub fn labelled(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }

    /// Add a `= note:` footer.
    #[must_use]
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.notes.push(note.into());
        self
    }

    /// Add a `= help:` footer — the remedy.
    #[must_use]
    pub fn with_help(mut self, help: impl Into<String>) -> Self {
        self.help = Some(help.into());
        self
    }

    /// The severity.
    #[must_use]
    pub fn severity(&self) -> Severity {
        self.severity
    }

    /// The stable id.
    #[must_use]
    pub fn id(&self) -> &'static str {
        self.id
    }

    /// The headline message.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }

    /// Render without colour — the default, for tests, pipes, and logs.
    #[must_use]
    pub fn render(&self) -> String {
        self.render_with(false)
    }

    /// Render with colour, for a real terminal.
    #[must_use]
    pub fn render_styled(&self) -> String {
        self.render_with(true)
    }

    /// Render styled when stderr is a terminal, plain otherwise.
    ///
    /// This is the one place that consults `isatty`, and it is at the point of
    /// printing: a captured stderr (a test, a pipe, a CI log) is not a terminal,
    /// so it gets plain bytes and its assertions match without stripping escapes,
    /// while an interactive run keeps its colour.
    #[must_use]
    pub fn render_auto(&self) -> String {
        if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
            self.render_styled()
        } else {
            self.render()
        }
    }

    fn render_with(&self, styled: bool) -> String {
        let renderer = if styled {
            Renderer::styled()
        } else {
            Renderer::plain()
        };
        let title = self
            .severity
            .level()
            .primary_title(self.message.clone())
            .id(self.id);
        let mut group = match self.source() {
            Some((location, text)) => {
                let label = self.label.clone().unwrap_or_else(|| self.message.clone());
                title.element(
                    Snippet::source(text)
                        .line_start(1)
                        .path(location.path.display().to_string())
                        .annotation(
                            AnnotationKind::Primary
                                .span(location.span.clone())
                                .label(label),
                        ),
                )
            }
            None => Group::with_title(title),
        };
        for note in &self.notes {
            group = group.element(Level::NOTE.message(note.clone()));
        }
        if let Some(help) = &self.help {
            group = group.element(Level::HELP.message(help.clone()));
        }
        // `annotate-snippets` ends every block with a newline; the crate's callers
        // join blocks and print them, so the trailing one is noise here.
        renderer.render(&[group]).trim_end().to_string()
    }

    fn source(&self) -> Option<(&Location, String)> {
        let location = self.location.as_ref()?;
        let text = std::fs::read_to_string(&location.path).ok()?;
        Some((location, text))
    }
}

/// Render several diagnostics, one block per line.
///
/// A refusal can carry more than one finding, and showing one at a time makes a
/// user fix and retry to discover the rest — the reason the old
/// `render_diagnostics` reported every pair. Order is preserved.
#[must_use]
pub fn render_all(diagnostics: &[Diagnostic]) -> String {
    diagnostics
        .iter()
        .map(Diagnostic::render)
        .collect::<Vec<_>>()
        .join("\n")
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    #[test]
    fn a_title_only_diagnostic_names_level_and_id() {
        let rendered = Diagnostic::error("E-tix-cfg", "tix.toml is invalid").render();
        assert_eq!(rendered, "error[E-tix-cfg]: tix.toml is invalid");
    }

    #[test]
    fn a_warning_renders_at_the_warning_level() {
        let rendered = Diagnostic::warning("W-tix-cfg", "no category map").render();
        assert!(rendered.starts_with("warning[W-tix-cfg]:"), "{rendered}");
    }

    #[test]
    fn notes_and_help_are_footers() {
        let rendered = Diagnostic::error("E-tix-x", "boom")
            .with_note("why it happened")
            .with_help("do this instead")
            .render();
        assert!(rendered.contains("note"), "{rendered}");
        assert!(rendered.contains("why it happened"), "{rendered}");
        assert!(rendered.contains("do this instead"), "{rendered}");
    }

    #[test]
    fn a_location_renders_a_snippet_with_the_path_and_span() {
        let dir = std::env::temp_dir().join("tix-diagnostics-test");
        std::fs::create_dir_all(&dir).unwrap();
        let path = dir.join("x.md");
        std::fs::write(&path, "line one\nline two\n").unwrap();
        let rendered = Diagnostic::error("E-tix-x", "bad value")
            .at(&path, 9..13)
            .labelled("this is the value")
            .render();
        assert!(rendered.contains("bad value"), "{rendered}");
        assert!(rendered.contains("x.md"), "{rendered}");
        assert!(rendered.contains("line two"), "{rendered}");
        assert!(rendered.contains("this is the value"), "{rendered}");
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn a_missing_file_degrades_to_a_title() {
        let rendered = Diagnostic::error("E-tix-x", "bad value")
            .at("/nonexistent/tix/diagnostics/x.md", 0..3)
            .render();
        assert_eq!(rendered, "error[E-tix-x]: bad value");
    }

    #[test]
    fn render_all_joins_one_block_per_line() {
        let rendered = render_all(&[
            Diagnostic::error("E-tix-a", "first"),
            Diagnostic::error("E-tix-a", "second"),
        ]);
        assert_eq!(rendered, "error[E-tix-a]: first\nerror[E-tix-a]: second");
    }

    #[test]
    fn styled_and_plain_carry_the_same_text() {
        let diagnostic = Diagnostic::error("E-tix-x", "boom").with_help("remedy");
        assert_eq!(strip_ansi(&diagnostic.render_styled()), diagnostic.render());
    }

    /// Remove ANSI escape sequences so a styled render can be compared to plain.
    fn strip_ansi(text: &str) -> String {
        let mut out = String::new();
        let mut chars = text.chars().peekable();
        while let Some(c) = chars.next() {
            if c == '\u{1b}' {
                for e in chars.by_ref() {
                    if e.is_ascii_alphabetic() {
                        break;
                    }
                }
            } else {
                out.push(c);
            }
        }
        out
    }
}