Skip to main content

tix/
error.rs

1//! Typed error surface for `tix`.
2//!
3//! Every fallible operation in the crate reports through [`TixError`],
4//! and the [`Result`] alias is the canonical return type used across the
5//! module boundaries. Consumers match on the variants rather than on
6//! ad-hoc strings; context strings carry human-readable detail.
7
8/// The crate-wide result type.
9pub type Result<T> = std::result::Result<T, TixError>;
10
11/// Render a set of field-level diagnostics as one user-facing message.
12///
13/// **The single renderer for validation refusals.** Both validation services use
14/// it, so `tix link` and `tix parent set` report refusals in the same format and a
15/// user who learned one does not have to learn the other. It lives here rather
16/// than in either service because neither owns the other's diagnostics, and a
17/// second copy of this loop is how the two formats drift apart.
18///
19/// Takes `(kind, message)` pairs rather than either service's diagnostic type, so
20/// this module needs to know nothing about `relationships` or `hierarchy`.
21///
22/// Three properties, each of which a service message made necessary:
23///
24/// * **Every** diagnostic is reported, not just the first. A rejection can carry
25///   several, and showing one at a time makes a user fix and retry repeatedly to
26///   discover the rest.
27/// * The kind is prefixed **unless the message already names it**. Several service
28///   messages do (`cycle: 1 -> 2 -> 1`), and prefixing unconditionally produced the
29///   doubled, stuttering `cycle: cycle: ...`.
30/// * Duplicates are collapsed. A self-link is reported by the self-link rule and
31///   again by the cycle rule, so without this the most common rejection printed the
32///   identical sentence twice. Collapsing is presentation, and leaves the
33///   diagnostic set the service produced intact for any other consumer.
34/// * The separator is ` | ` rather than `; ` because service messages contain
35///   `; ` themselves, which made the rendered text ambiguous about where one
36///   diagnostic ended and the next began.
37pub fn render_diagnostics<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
38    let mut seen: Vec<(&str, &str)> = Vec::new();
39    let mut rendered: Vec<String> = Vec::new();
40    for (kind, message) in pairs {
41        if seen.iter().any(|(k, m)| *k == kind && *m == message) {
42            continue;
43        }
44        seen.push((kind, message));
45        rendered.push(if message.starts_with(kind) {
46            message.to_string()
47        } else {
48            format!("{kind}: {message}")
49        });
50    }
51    rendered.join(" | ")
52}
53
54/// Typed errors raised by `tix`.
55#[derive(Debug, thiserror::Error)]
56pub enum TixError {
57    /// An I/O operation failed (read/write/rename/remove).
58    #[error("io error: {0}")]
59    Io(#[from] std::io::Error),
60
61    /// The transition service refused a status change (contract §3.5).
62    ///
63    /// WP03. Carries the **diagnostic**, not a pre-formatted string, so the named
64    /// kind reaches the surface intact: the CLI, the TUI, MCP and lint all report
65    /// one condition under one name (contract §6). A refusal that arrives as a
66    /// bare `InvalidInput` would be indistinguishable from a typo caught by
67    /// argument parsing, and the caller could not tell "you asked for a column
68    /// that does not exist" from "that move is against policy".
69    ///
70    /// Reaching this variant is itself the contract: **nothing was written** — no
71    /// issue file, no `tix.toml`, no History entry. The seam guarantees it.
72    #[error("transition rejected {0}")]
73    Workflow(crate::workflow::WorkflowDiagnostic),
74
75    /// `tix.toml` or other config surface is invalid.
76    #[error("config error: {0}")]
77    Config(String),
78
79    /// A data file (issue frontmatter, config) could not be parsed.
80    #[error("parse error: {0}")]
81    Parse(String),
82
83    /// The requested issue/config path does not exist.
84    #[error("not found: {0}")]
85    NotFound(String),
86
87    /// User input failed validation.
88    #[error("invalid input: {0}")]
89    InvalidInput(String),
90
91    /// Launching or interacting with the `$EDITOR` failed.
92    #[error("editor error: {0}")]
93    Editor(String),
94
95    /// An external tool (`glab`, `linear-cli`, ...) failed.
96    #[error("external tool error: {0}")]
97    External(String),
98
99    /// Spec Kitty bridge error (`kitty-specs/` read, parse, or link).
100    #[error("kitty error: {0}")]
101    Kitty(String),
102}
103
104impl TixError {
105    /// Wrap an error with a path prefix for context.
106    pub fn with_path(self, path: impl std::fmt::Display) -> Self {
107        match self {
108            TixError::Io(e) => TixError::Io(e),
109            TixError::Config(msg) => TixError::Config(format!("{path}: {msg}")),
110            TixError::Parse(msg) => TixError::Parse(format!("{path}: {msg}")),
111            TixError::NotFound(msg) => TixError::NotFound(format!("{path}: {msg}")),
112            other => other,
113        }
114    }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn result_alias_resolves_to_tix_error() {
123        let err: TixError = TixError::InvalidInput("nope".into());
124        let r: Result<()> = Err(err);
125        assert!(r.is_err());
126    }
127
128    #[test]
129    fn config_error_carries_path_when_prefixed() {
130        let e = TixError::Config("bad keys".into()).with_path("tix.toml");
131        assert_eq!(e.to_string(), "config error: tix.toml: bad keys");
132    }
133}