semver_analyzer_core/error.rs
1//! Error tip and diagnostic wrapper types for user-facing error reporting.
2//!
3//! This module provides the error reporting contract for the semver-analyzer:
4//!
5//! - [`ErrorTip`] — Trait for errors that carry remediation tips
6//! - [`Diagnosed`] — Marker type that carries tips through the `anyhow` chain
7//! - [`DiagnoseWithTip`] — Extension trait for `Result<T, E: ErrorTip>`
8//! - [`DiagnoseExt`] — Extension trait for attaching explicit tip strings
9//!
10//! ## How It Works
11//!
12//! Language implementations define domain-specific error types (e.g.,
13//! `WorktreeError` for TypeScript) and implement `ErrorTip` on them.
14//! At the boundary where errors enter `anyhow::Result`, call `.diagnose()`
15//! to capture the tip into a `Diagnosed` marker. The CLI renderer walks
16//! the `anyhow` chain and extracts the tip via a single
17//! `downcast_ref::<Diagnosed>()`.
18//!
19//! ```text
20//! Language impl Orchestrator CLI
21//! ───────────── ──────────── ───
22//! WorktreeError::TscFailed .context("...") render_error()
23//! → .diagnose() → propagates → downcast Diagnosed
24//! → Diagnosed { tip } added via ? → shows tip
25//! ```
26
27use std::fmt;
28
29// ── ErrorTip trait ─────────────────────────────────────────────────────
30
31/// Contract for errors that carry user-facing remediation tips.
32///
33/// Implement this on any error type that should provide actionable
34/// guidance when rendered to the user. The tip is captured into a
35/// [`Diagnosed`] wrapper via [`.diagnose()`](DiagnoseWithTip::diagnose)
36/// at the error boundary.
37///
38/// # For Language implementors
39///
40/// 1. Define your error enum with `thiserror::Error`
41/// 2. Implement `ErrorTip` — every variant a user can trigger MUST have a tip
42/// 3. At the boundary where the error enters `anyhow::Result`, call `.diagnose()`
43///
44/// # Example
45///
46/// ```rust,ignore
47/// impl ErrorTip for WorktreeError {
48/// fn tip(&self) -> Option<String> {
49/// Some(match self {
50/// Self::TscFailed { error_count, .. } => format!(
51/// "TypeScript compilation failed with {} error(s).\n\
52/// Use --log-file debug.log to see full tsc output.",
53/// error_count
54/// ),
55/// // ...
56/// })
57/// }
58/// }
59///
60/// // At boundary:
61/// let guard = WorktreeGuard::new(repo, ref, cmd).diagnose()?;
62/// ```
63pub trait ErrorTip: std::error::Error {
64 /// Return a remediation tip for this error, if one is available.
65 ///
66 /// Each line in the returned string is a separate suggestion.
67 /// Return `None` for errors where no actionable advice exists
68 /// (e.g., `Io` transparent wrappers).
69 fn tip(&self) -> Option<String>;
70}
71
72// ── Diagnosed wrapper ──────────────────────────────────────────────────
73
74/// Marker type that carries a user-facing tip through the `anyhow` error chain.
75///
76/// Added automatically by [`.diagnose()`](DiagnoseWithTip::diagnose) or
77/// [`.with_diagnosis()`](DiagnoseExt::with_diagnosis). The CLI renderer
78/// extracts it via a single `downcast_ref::<Diagnosed>()` — no per-type
79/// dispatch needed.
80///
81/// `Display` returns an empty string so the marker is invisible in the
82/// error chain's "caused by" output.
83#[derive(Debug)]
84pub struct Diagnosed {
85 tip: String,
86}
87
88impl Diagnosed {
89 /// Create a new diagnosed marker with the given tip.
90 pub fn new(tip: impl Into<String>) -> Self {
91 Self { tip: tip.into() }
92 }
93
94 /// Get the remediation tip.
95 pub fn tip(&self) -> &str {
96 &self.tip
97 }
98}
99
100impl fmt::Display for Diagnosed {
101 fn fmt(&self, _f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 // Invisible in the error chain — just a tip carrier
103 Ok(())
104 }
105}
106
107impl std::error::Error for Diagnosed {}
108
109/// Internal error wrapper that carries a tip alongside the original error.
110///
111/// This is the actual type inserted into the anyhow chain. The CLI
112/// extracts the tip by downcasting to `Diagnosed` (which this derefs to
113/// via the chain's source).
114///
115/// The `Display` impl delegates to the source error so the tip doesn't
116/// appear in the error message — only in the rendered output.
117#[derive(Debug)]
118pub struct DiagnosedError {
119 tip: String,
120 source: anyhow::Error,
121}
122
123impl fmt::Display for DiagnosedError {
124 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125 // Display the source error, not the tip
126 write!(f, "{}", self.source)
127 }
128}
129
130impl std::error::Error for DiagnosedError {
131 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
132 self.source.source()
133 }
134}
135
136impl DiagnosedError {
137 /// Get the remediation tip.
138 pub fn tip(&self) -> &str {
139 &self.tip
140 }
141}
142
143// ── DiagnoseWithTip extension ──────────────────────────────────────────
144
145/// Extension trait for `Result<T, E>` where `E` implements [`ErrorTip`].
146///
147/// Automatically extracts the tip from the error and wraps it in a
148/// [`Diagnosed`] context layer on the `anyhow::Error`.
149pub trait DiagnoseWithTip<T> {
150 /// Convert the error to `anyhow::Error` and attach its tip as a
151 /// [`Diagnosed`] context layer.
152 ///
153 /// If the error's `tip()` returns `None`, the error is converted
154 /// to `anyhow::Error` without a `Diagnosed` marker.
155 fn diagnose(self) -> anyhow::Result<T>;
156}
157
158impl<T, E> DiagnoseWithTip<T> for Result<T, E>
159where
160 E: ErrorTip + Send + Sync + 'static,
161{
162 fn diagnose(self) -> anyhow::Result<T> {
163 self.map_err(|e| {
164 let tip = e.tip();
165 let err: anyhow::Error = e.into();
166 match tip {
167 Some(t) => {
168 // Wrap in Diagnosed as the outer error, with the original
169 // as its source. This way downcast_ref::<Diagnosed>() works
170 // on the chain because Diagnosed is the outermost error.
171 let diagnosed = DiagnosedError {
172 tip: t,
173 source: err,
174 };
175 anyhow::Error::new(diagnosed)
176 }
177 None => err,
178 }
179 })
180 }
181}
182
183// ── DiagnoseExt extension ──────────────────────────────────────────────
184
185/// Extension trait for attaching an explicit tip string to any `Result`.
186///
187/// Use this for errors that don't implement [`ErrorTip`] but where you
188/// still want to provide remediation guidance.
189///
190/// # Example
191///
192/// ```rust,ignore
193/// use semver_analyzer_core::error::DiagnoseExt;
194///
195/// fs::read(path)
196/// .with_context(|| format!("Failed to read {}", path.display()))
197/// .with_diagnosis("Check the file exists and you have read permissions.")?;
198/// ```
199pub trait DiagnoseExt<T> {
200 /// Attach a remediation tip to the error.
201 fn with_diagnosis(self, tip: impl Into<String>) -> anyhow::Result<T>;
202}
203
204impl<T, E> DiagnoseExt<T> for Result<T, E>
205where
206 E: Into<anyhow::Error>,
207{
208 fn with_diagnosis(self, tip: impl Into<String>) -> anyhow::Result<T> {
209 self.map_err(|e| {
210 let source = e.into();
211 anyhow::Error::new(DiagnosedError {
212 tip: tip.into(),
213 source,
214 })
215 })
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 // A test error type implementing ErrorTip
224 #[derive(Debug)]
225 struct TestError {
226 msg: String,
227 has_tip: bool,
228 }
229
230 impl fmt::Display for TestError {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 write!(f, "{}", self.msg)
233 }
234 }
235
236 impl std::error::Error for TestError {}
237
238 impl ErrorTip for TestError {
239 fn tip(&self) -> Option<String> {
240 if self.has_tip {
241 Some("Try doing X instead.".into())
242 } else {
243 None
244 }
245 }
246 }
247
248 #[test]
249 fn diagnose_captures_tip() {
250 let result: Result<(), TestError> = Err(TestError {
251 msg: "something broke".into(),
252 has_tip: true,
253 });
254
255 let err = result.diagnose().unwrap_err();
256
257 // The outermost error should be DiagnosedError
258 let diagnosed = err.downcast_ref::<DiagnosedError>();
259 assert!(
260 diagnosed.is_some(),
261 "DiagnosedError not found at top of chain"
262 );
263 assert_eq!(diagnosed.unwrap().tip(), "Try doing X instead.");
264 }
265
266 #[test]
267 fn diagnose_without_tip_skips_marker() {
268 let result: Result<(), TestError> = Err(TestError {
269 msg: "something broke".into(),
270 has_tip: false,
271 });
272
273 let err = result.diagnose().unwrap_err();
274
275 // No DiagnosedError should be present
276 assert!(
277 err.downcast_ref::<DiagnosedError>().is_none(),
278 "DiagnosedError should not be present when tip() returns None"
279 );
280 }
281
282 #[test]
283 fn with_diagnosis_attaches_tip() {
284 let result: Result<(), std::io::Error> = Err(std::io::Error::new(
285 std::io::ErrorKind::NotFound,
286 "file not found",
287 ));
288
289 let err = result.with_diagnosis("Check the path exists.").unwrap_err();
290
291 let diagnosed = err.downcast_ref::<DiagnosedError>();
292 assert!(
293 diagnosed.is_some(),
294 "DiagnosedError not found at top of chain"
295 );
296 assert_eq!(diagnosed.unwrap().tip(), "Check the path exists.");
297 }
298
299 #[test]
300 fn diagnosed_display_is_empty() {
301 let d = Diagnosed::new("some tip");
302 assert_eq!(d.to_string(), "");
303 }
304}