Skip to main content

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}