tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Typed error surface for `tix`.
//!
//! Every fallible operation in the crate reports through [`TixError`],
//! and the [`Result`] alias is the canonical return type used across the
//! module boundaries. Consumers match on the variants rather than on
//! ad-hoc strings; context strings carry human-readable detail.

use crate::diagnostics::Diagnostic;

/// The crate-wide result type.
///
/// The error type is a **defaulted** parameter so the common `Result<T>` stays
/// short while a call site that needs a precise error can still name it.
pub type Result<T, E = TixError> = std::result::Result<T, E>;

/// Render a set of field-level diagnostics as one user-facing block.
///
/// **The single renderer for validation refusals.** Both validation services use
/// it, so `tix link` and `tix parent set` report refusals in the same format and a
/// user who learned one does not have to learn the other. It lives here rather
/// than in either service because neither owns the other's diagnostics, and a
/// second copy of this loop is how the two formats drift apart.
///
/// `id` names the surface (`E-tix-link`, `E-tix-hierarchy`, …); it is passed in
/// because only the caller knows which command is refusing. Rendering is
/// delegated to [`crate::diagnostics`], so a refusal here and a `tix lint` finding
/// there are the same rustc-style block.
///
/// Three properties, each of which a service message made necessary:
///
/// * **Every** diagnostic is reported, not just the first. A rejection can carry
///   several, and showing one at a time makes a user fix and retry repeatedly to
///   discover the rest.
/// * The kind is prefixed **unless the message already names it**. Several service
///   messages do (`cycle: 1 -> 2 -> 1`), and prefixing unconditionally produced the
///   doubled, stuttering `cycle: cycle: ...`.
/// * Duplicates are collapsed. A self-link is reported by the self-link rule and
///   again by the cycle rule, so without this the most common rejection printed the
///   identical sentence twice. Collapsing is presentation, and leaves the
///   diagnostic set the service produced intact for any other consumer.
pub fn render_diagnostics<'a>(
    id: &'static str,
    pairs: impl IntoIterator<Item = (&'a str, &'a str)>,
) -> String {
    let mut seen: Vec<(&str, &str)> = Vec::new();
    let mut rendered: Vec<Diagnostic> = Vec::new();
    for (kind, message) in pairs {
        if seen.iter().any(|(k, m)| *k == kind && *m == message) {
            continue;
        }
        seen.push((kind, message));
        let text = if message.starts_with(kind) {
            message.to_string()
        } else {
            format!("{kind}: {message}")
        };
        rendered.push(Diagnostic::error(id, text));
    }
    crate::diagnostics::render_all(&rendered)
}

/// Join `(kind, message)` pairs into the plain `kind: message | …` sentence.
///
/// The **legacy, un-rendered** form, for callers that embed the result inside
/// another value and let a boundary render it later: a batch item stores its
/// refusal as a plain message and the CLI/MCP render the *item outcome*, not each
/// fragment. Every pair is kept, a message already leading with its kind is not
/// prefixed twice, and duplicates collapse — the three properties the rendered
/// form keeps too, so the two do not drift.
#[must_use]
pub fn render_diagnostics_plain<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
    let mut seen: Vec<(&str, &str)> = Vec::new();
    let mut rendered: Vec<String> = Vec::new();
    for (kind, message) in pairs {
        if seen.iter().any(|(k, m)| *k == kind && *m == message) {
            continue;
        }
        seen.push((kind, message));
        rendered.push(if message.starts_with(kind) {
            message.to_string()
        } else {
            format!("{kind}: {message}")
        });
    }
    rendered.join(" | ")
}

/// Render any [`TixError`] as one rustc-style block.
///
/// **The single boundary renderer for errors.** The CLI prints it to stderr and
/// MCP returns it as the `isError` text, so the two surfaces cannot drift: their
/// parity is not maintained by two similar `format!`s but by there being one
/// function. [`TixError::Refused`] already carries a rendered block and is
/// returned verbatim; every other variant is rendered from its `Display` text
/// under a stable [`TixError::diagnostic_id`].
#[must_use]
pub fn render_error(err: &TixError) -> String {
    if let TixError::Refused(block) = err {
        return block.clone();
    }
    Diagnostic::error(err.diagnostic_id(), err.to_string()).render()
}

/// Typed errors raised by `tix`.
#[derive(Debug, thiserror::Error)]
pub enum TixError {
    /// An I/O operation failed (read/write/rename/remove).
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    /// The transition service refused a status change (contract §3.5).
    ///
    /// WP03. Carries the **diagnostic**, not a pre-formatted string, so the named
    /// kind reaches the surface intact: the CLI, the TUI, MCP and lint all report
    /// one condition under one name (contract §6). A refusal that arrives as a
    /// bare `InvalidInput` would be indistinguishable from a typo caught by
    /// argument parsing, and the caller could not tell "you asked for a column
    /// that does not exist" from "that move is against policy".
    ///
    /// Reaching this variant is itself the contract: **nothing was written** — no
    /// issue file, no `tix.toml`, no History entry. The seam guarantees it.
    #[error("transition rejected {0}")]
    Workflow(crate::workflow::WorkflowDiagnostic),

    /// `tix.toml` or other config surface is invalid.
    #[error("config error: {0}")]
    Config(String),

    /// A data file (issue frontmatter, config) could not be parsed.
    #[error("parse error: {0}")]
    Parse(String),

    /// The requested issue/config path does not exist.
    #[error("not found: {0}")]
    NotFound(String),

    /// User input failed validation.
    #[error("invalid input: {0}")]
    InvalidInput(String),

    /// A validation refusal whose findings are **already a rendered block**.
    ///
    /// [`render_diagnostics`] renders the service's findings through
    /// [`crate::diagnostics`], so by the time they reach an error they are a
    /// rustc-style block, not a bare message. Wrapping that in the `invalid input:`
    /// prefix would put a second, non-rustc prefix in front of a rustc block — so
    /// this variant prints its payload verbatim (`#[error("{0}")]`), and
    /// [`render_error`] returns it unchanged.
    #[error("{0}")]
    Refused(String),

    /// Launching or interacting with the `$EDITOR` failed.
    #[error("editor error: {0}")]
    Editor(String),

    /// An external tool (`glab`, `linear-cli`, ...) failed.
    #[error("external tool error: {0}")]
    External(String),

    /// Spec Kitty bridge error (`kitty-specs/` read, parse, or link).
    #[error("kitty error: {0}")]
    Kitty(String),
}

impl TixError {
    /// The stable rendering id for this error, used by [`render_error`].
    ///
    /// One id per variant, so the CLI, MCP, and the TUI all bracket the same
    /// condition with the same name. `Refused` has no id of its own — its payload
    /// already carries one.
    #[must_use]
    pub fn diagnostic_id(&self) -> &'static str {
        match self {
            TixError::Io(_) => "E-tix-io",
            TixError::Workflow(_) => "E-tix-workflow",
            TixError::Config(_) => "E-tix-config",
            TixError::Parse(_) => "E-tix-parse",
            TixError::NotFound(_) => "E-tix-not-found",
            TixError::InvalidInput(_) => "E-tix-invalid-input",
            TixError::Refused(_) => "E-tix-refused",
            TixError::Editor(_) => "E-tix-editor",
            TixError::External(_) => "E-tix-external",
            TixError::Kitty(_) => "E-tix-kitty",
        }
    }

    /// Wrap an error with a path prefix for context.
    pub fn with_path(self, path: impl std::fmt::Display) -> Self {
        match self {
            TixError::Io(e) => TixError::Io(e),
            TixError::Config(msg) => TixError::Config(format!("{path}: {msg}")),
            TixError::Parse(msg) => TixError::Parse(format!("{path}: {msg}")),
            TixError::NotFound(msg) => TixError::NotFound(format!("{path}: {msg}")),
            other => other,
        }
    }
}

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

    #[test]
    fn result_alias_resolves_to_tix_error() {
        let err: TixError = TixError::InvalidInput("nope".into());
        let r: Result<()> = Err(err);
        assert!(r.is_err());
    }

    #[test]
    fn config_error_carries_path_when_prefixed() {
        let e = TixError::Config("bad keys".into()).with_path("tix.toml");
        assert_eq!(e.to_string(), "config error: tix.toml: bad keys");
    }
}