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}