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}