tix-tracker 0.6.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.

/// The crate-wide result type.
pub type Result<T> = std::result::Result<T, TixError>;

/// Render a set of field-level diagnostics as one user-facing message.
///
/// **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.
///
/// Takes `(kind, message)` pairs rather than either service's diagnostic type, so
/// this module needs to know nothing about `relationships` or `hierarchy`.
///
/// 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.
/// * The separator is ` | ` rather than `; ` because service messages contain
///   `; ` themselves, which made the rendered text ambiguous about where one
///   diagnostic ended and the next began.
pub fn render_diagnostics<'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(" | ")
}

/// 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),

    /// 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 {
    /// 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");
    }
}