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
8use crate::diagnostics::Diagnostic;
9
10/// The crate-wide result type.
11///
12/// The error type is a **defaulted** parameter so the common `Result<T>` stays
13/// short while a call site that needs a precise error can still name it.
14pub type Result<T, E = TixError> = std::result::Result<T, E>;
15
16/// Render a set of field-level diagnostics as one user-facing block.
17///
18/// **The single renderer for validation refusals.** Both validation services use
19/// it, so `tix link` and `tix parent set` report refusals in the same format and a
20/// user who learned one does not have to learn the other. It lives here rather
21/// than in either service because neither owns the other's diagnostics, and a
22/// second copy of this loop is how the two formats drift apart.
23///
24/// `id` names the surface (`E-tix-link`, `E-tix-hierarchy`, …); it is passed in
25/// because only the caller knows which command is refusing. Rendering is
26/// delegated to [`crate::diagnostics`], so a refusal here and a `tix lint` finding
27/// there are the same rustc-style block.
28///
29/// Three properties, each of which a service message made necessary:
30///
31/// * **Every** diagnostic is reported, not just the first. A rejection can carry
32///   several, and showing one at a time makes a user fix and retry repeatedly to
33///   discover the rest.
34/// * The kind is prefixed **unless the message already names it**. Several service
35///   messages do (`cycle: 1 -> 2 -> 1`), and prefixing unconditionally produced the
36///   doubled, stuttering `cycle: cycle: ...`.
37/// * Duplicates are collapsed. A self-link is reported by the self-link rule and
38///   again by the cycle rule, so without this the most common rejection printed the
39///   identical sentence twice. Collapsing is presentation, and leaves the
40///   diagnostic set the service produced intact for any other consumer.
41pub fn render_diagnostics<'a>(
42    id: &'static str,
43    pairs: impl IntoIterator<Item = (&'a str, &'a str)>,
44) -> String {
45    let mut seen: Vec<(&str, &str)> = Vec::new();
46    let mut rendered: Vec<Diagnostic> = Vec::new();
47    for (kind, message) in pairs {
48        if seen.iter().any(|(k, m)| *k == kind && *m == message) {
49            continue;
50        }
51        seen.push((kind, message));
52        let text = if message.starts_with(kind) {
53            message.to_string()
54        } else {
55            format!("{kind}: {message}")
56        };
57        rendered.push(Diagnostic::error(id, text));
58    }
59    crate::diagnostics::render_all(&rendered)
60}
61
62/// Join `(kind, message)` pairs into the plain `kind: message | …` sentence.
63///
64/// The **legacy, un-rendered** form, for callers that embed the result inside
65/// another value and let a boundary render it later: a batch item stores its
66/// refusal as a plain message and the CLI/MCP render the *item outcome*, not each
67/// fragment. Every pair is kept, a message already leading with its kind is not
68/// prefixed twice, and duplicates collapse — the three properties the rendered
69/// form keeps too, so the two do not drift.
70#[must_use]
71pub fn render_diagnostics_plain<'a>(pairs: impl IntoIterator<Item = (&'a str, &'a str)>) -> String {
72    let mut seen: Vec<(&str, &str)> = Vec::new();
73    let mut rendered: Vec<String> = Vec::new();
74    for (kind, message) in pairs {
75        if seen.iter().any(|(k, m)| *k == kind && *m == message) {
76            continue;
77        }
78        seen.push((kind, message));
79        rendered.push(if message.starts_with(kind) {
80            message.to_string()
81        } else {
82            format!("{kind}: {message}")
83        });
84    }
85    rendered.join(" | ")
86}
87
88/// Render any [`TixError`] as one rustc-style block.
89///
90/// **The single boundary renderer for errors.** The CLI prints it to stderr and
91/// MCP returns it as the `isError` text, so the two surfaces cannot drift: their
92/// parity is not maintained by two similar `format!`s but by there being one
93/// function. [`TixError::Refused`] already carries a rendered block and is
94/// returned verbatim; every other variant is rendered from its `Display` text
95/// under a stable [`TixError::diagnostic_id`].
96#[must_use]
97pub fn render_error(err: &TixError) -> String {
98    if let TixError::Refused(block) = err {
99        return block.clone();
100    }
101    Diagnostic::error(err.diagnostic_id(), err.to_string()).render()
102}
103
104/// Typed errors raised by `tix`.
105#[derive(Debug, thiserror::Error)]
106pub enum TixError {
107    /// An I/O operation failed (read/write/rename/remove).
108    #[error("io error: {0}")]
109    Io(#[from] std::io::Error),
110
111    /// The transition service refused a status change (contract §3.5).
112    ///
113    /// WP03. Carries the **diagnostic**, not a pre-formatted string, so the named
114    /// kind reaches the surface intact: the CLI, the TUI, MCP and lint all report
115    /// one condition under one name (contract §6). A refusal that arrives as a
116    /// bare `InvalidInput` would be indistinguishable from a typo caught by
117    /// argument parsing, and the caller could not tell "you asked for a column
118    /// that does not exist" from "that move is against policy".
119    ///
120    /// Reaching this variant is itself the contract: **nothing was written** — no
121    /// issue file, no `tix.toml`, no History entry. The seam guarantees it.
122    #[error("transition rejected {0}")]
123    Workflow(crate::workflow::WorkflowDiagnostic),
124
125    /// `tix.toml` or other config surface is invalid.
126    #[error("config error: {0}")]
127    Config(String),
128
129    /// A data file (issue frontmatter, config) could not be parsed.
130    #[error("parse error: {0}")]
131    Parse(String),
132
133    /// The requested issue/config path does not exist.
134    #[error("not found: {0}")]
135    NotFound(String),
136
137    /// User input failed validation.
138    #[error("invalid input: {0}")]
139    InvalidInput(String),
140
141    /// A validation refusal whose findings are **already a rendered block**.
142    ///
143    /// [`render_diagnostics`] renders the service's findings through
144    /// [`crate::diagnostics`], so by the time they reach an error they are a
145    /// rustc-style block, not a bare message. Wrapping that in the `invalid input:`
146    /// prefix would put a second, non-rustc prefix in front of a rustc block — so
147    /// this variant prints its payload verbatim (`#[error("{0}")]`), and
148    /// [`render_error`] returns it unchanged.
149    #[error("{0}")]
150    Refused(String),
151
152    /// Launching or interacting with the `$EDITOR` failed.
153    #[error("editor error: {0}")]
154    Editor(String),
155
156    /// An external tool (`glab`, `linear-cli`, ...) failed.
157    #[error("external tool error: {0}")]
158    External(String),
159
160    /// Spec Kitty bridge error (`kitty-specs/` read, parse, or link).
161    #[error("kitty error: {0}")]
162    Kitty(String),
163}
164
165impl TixError {
166    /// The stable rendering id for this error, used by [`render_error`].
167    ///
168    /// One id per variant, so the CLI, MCP, and the TUI all bracket the same
169    /// condition with the same name. `Refused` has no id of its own — its payload
170    /// already carries one.
171    #[must_use]
172    pub fn diagnostic_id(&self) -> &'static str {
173        match self {
174            TixError::Io(_) => "E-tix-io",
175            TixError::Workflow(_) => "E-tix-workflow",
176            TixError::Config(_) => "E-tix-config",
177            TixError::Parse(_) => "E-tix-parse",
178            TixError::NotFound(_) => "E-tix-not-found",
179            TixError::InvalidInput(_) => "E-tix-invalid-input",
180            TixError::Refused(_) => "E-tix-refused",
181            TixError::Editor(_) => "E-tix-editor",
182            TixError::External(_) => "E-tix-external",
183            TixError::Kitty(_) => "E-tix-kitty",
184        }
185    }
186
187    /// Wrap an error with a path prefix for context.
188    pub fn with_path(self, path: impl std::fmt::Display) -> Self {
189        match self {
190            TixError::Io(e) => TixError::Io(e),
191            TixError::Config(msg) => TixError::Config(format!("{path}: {msg}")),
192            TixError::Parse(msg) => TixError::Parse(format!("{path}: {msg}")),
193            TixError::NotFound(msg) => TixError::NotFound(format!("{path}: {msg}")),
194            other => other,
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    #[allow(clippy::wildcard_imports)]
202    use super::*;
203
204    #[test]
205    fn result_alias_resolves_to_tix_error() {
206        let err: TixError = TixError::InvalidInput("nope".into());
207        let r: Result<()> = Err(err);
208        assert!(r.is_err());
209    }
210
211    #[test]
212    fn config_error_carries_path_when_prefixed() {
213        let e = TixError::Config("bad keys".into()).with_path("tix.toml");
214        assert_eq!(e.to_string(), "config error: tix.toml: bad keys");
215    }
216}