Skip to main content

rmux_sdk/
error.rs

1//! SDK facade errors.
2//!
3//! `RmuxError` is the SDK-facing error boundary for daemon-backed operations.
4//! It intentionally does not implement [`Clone`], even when it wraps cloneable
5//! lower-crate protocol diagnostics.
6
7use std::error::Error;
8use std::fmt;
9use std::io;
10
11use crate::broadcast::PartialBroadcastFailure;
12use crate::diagnostics;
13use crate::wait::WaitTimeoutError;
14use crate::{PaneId, SessionName};
15
16const PROTOCOL_HINT: &str =
17    "check the request and daemon state, then retry after correcting the request";
18const TRANSPORT_HINT: &str = "verify the rmux daemon is running and the endpoint is reachable";
19
20/// SDK facade error type for daemon-backed operations.
21///
22/// The type is deliberately not `Clone`: error surfaces that need duplication
23/// should wrap in `Arc` rather than fan out cheap copies of opaque diagnostics.
24#[derive(Debug)]
25#[non_exhaustive]
26pub enum RmuxError {
27    /// A capability or operation is not supported by the negotiated
28    /// daemon. Carries a stable feature identifier and a visible recovery
29    /// hint so the SDK can map lower-crate typed unsupported errors to a
30    /// consistent surface.
31    #[non_exhaustive]
32    Unsupported {
33        /// Stable, machine-readable identifier for the unsupported
34        /// operation. Used by callers that pattern-match on capabilities.
35        feature: String,
36        /// Visible recovery hint shown after the human-readable message.
37        hint: String,
38    },
39    /// A protocol-level daemon response or local protocol validation failure.
40    #[non_exhaustive]
41    Protocol {
42        /// Lower-crate protocol diagnostic preserved as the source error.
43        source: rmux_proto::RmuxError,
44    },
45    /// A local transport failure while communicating with the daemon.
46    #[non_exhaustive]
47    Transport {
48        /// Operation that was attempted when the transport failed.
49        operation: String,
50        /// Underlying I/O failure preserved as the source error.
51        source: io::Error,
52    },
53    /// Multiple SDK diagnostics collected while evaluating one operation.
54    #[non_exhaustive]
55    Collect {
56        /// Aggregated diagnostics preserved as the source error.
57        source: CollectError,
58    },
59    /// A broadcast reached at least one pane and failed for at least one pane.
60    #[non_exhaustive]
61    PartialBroadcast {
62        /// Per-pane successes and failures.
63        source: PartialBroadcastFailure,
64    },
65    /// A visible wait timed out and retained the last observed snapshot.
66    #[non_exhaustive]
67    WaitTimeout {
68        /// Timeout details and last visible snapshot.
69        source: WaitTimeoutError,
70    },
71    /// A stable pane id no longer resolves in the addressed session.
72    #[non_exhaustive]
73    PaneNotFound {
74        /// Session searched for the pane id.
75        session_name: SessionName,
76        /// Stable pane id requested by the caller.
77        pane_id: PaneId,
78    },
79    /// The pane still has a running process and replacement was not requested.
80    #[non_exhaustive]
81    ProcessStillRunning {
82        /// Server diagnostic describing the active process conflict.
83        message: String,
84    },
85    /// A daemon-side process spawn failed.
86    #[non_exhaustive]
87    SpawnFailed {
88        /// Server diagnostic describing the spawn failure.
89        message: String,
90    },
91    /// A caller supplied an invalid regular expression.
92    #[non_exhaustive]
93    InvalidRegex {
94        /// Pattern supplied by the caller.
95        pattern: String,
96        /// Regex compiler diagnostic.
97        message: String,
98    },
99    /// A daemon-side owned-session lease was not found or no longer matches.
100    #[non_exhaustive]
101    OwnedSessionLeaseLost {
102        /// Server diagnostic describing the lost lease.
103        message: String,
104    },
105}
106
107impl RmuxError {
108    /// Creates an unsupported-feature error with a stable identifier and
109    /// visible recovery hint.
110    #[must_use]
111    pub fn unsupported(feature: impl Into<String>, hint: impl Into<String>) -> Self {
112        Self::Unsupported {
113            feature: feature.into(),
114            hint: hint.into(),
115        }
116    }
117
118    /// Creates an SDK protocol error from a lower-crate protocol diagnostic.
119    ///
120    /// Negotiation and capability mismatches are normalized to
121    /// [`RmuxError::Unsupported`] so callers can use [`RmuxError::feature`] and
122    /// [`RmuxError::hint`] without parsing lower-crate display text.
123    #[must_use]
124    pub fn protocol(error: rmux_proto::RmuxError) -> Self {
125        match error {
126            rmux_proto::RmuxError::UnsupportedWireVersion {
127                got,
128                minimum,
129                maximum,
130            } => Self::unsupported(
131                diagnostics::FEATURE_PROTOCOL_WIRE_VERSION,
132                format!(
133                    "upgrade the rmux daemon or use an SDK that supports wire version {got} \
134                     (supported range {minimum}..={maximum})"
135                ),
136            ),
137            rmux_proto::RmuxError::UnsupportedCapability { feature, supported } => {
138                let hint = diagnostics::unsupported_capability_hint(&feature, &supported);
139                Self::unsupported(feature, hint)
140            }
141            rmux_proto::RmuxError::UnknownCommand(command) => {
142                let feature = diagnostics::command_feature_id(&command);
143                Self::unsupported(
144                    feature,
145                    format!(
146                        "upgrade the rmux daemon or use a command advertised by the negotiated \
147                         command inventory before sending `{command}`"
148                    ),
149                )
150            }
151            source => map_known_protocol_error(source),
152        }
153    }
154
155    /// Creates an SDK transport error for a daemon communication operation.
156    #[must_use]
157    pub fn transport(operation: impl Into<String>, source: io::Error) -> Self {
158        Self::Transport {
159            operation: operation.into(),
160            source,
161        }
162    }
163
164    /// Creates an SDK aggregate error from collected diagnostics.
165    #[must_use]
166    pub fn collect(source: CollectError) -> Self {
167        Self::Collect { source }
168    }
169
170    /// Creates a partial-broadcast error with per-pane results.
171    #[must_use]
172    pub fn partial_broadcast(source: PartialBroadcastFailure) -> Self {
173        Self::PartialBroadcast { source }
174    }
175
176    /// Creates a visible-wait timeout error.
177    #[must_use]
178    pub fn wait_timeout(source: WaitTimeoutError) -> Self {
179        Self::WaitTimeout { source }
180    }
181
182    /// Creates a typed stable-pane-missing error.
183    #[must_use]
184    pub fn pane_not_found(session_name: SessionName, pane_id: PaneId) -> Self {
185        Self::PaneNotFound {
186            session_name,
187            pane_id,
188        }
189    }
190
191    /// Creates a typed regex validation error.
192    #[must_use]
193    pub fn invalid_regex(pattern: impl Into<String>, message: impl Into<String>) -> Self {
194        Self::InvalidRegex {
195            pattern: pattern.into(),
196            message: message.into(),
197        }
198    }
199
200    /// Returns the visible recovery hint associated with this error,
201    /// if one is recorded for the variant.
202    ///
203    /// Aggregate errors return `None`; inspect the contained diagnostics with
204    /// [`CollectError::errors`] to read each individual hint.
205    #[must_use]
206    pub fn hint(&self) -> Option<&str> {
207        match self {
208            Self::Unsupported { hint, .. } => Some(hint),
209            Self::Protocol { .. } => Some(PROTOCOL_HINT),
210            Self::Transport { .. } => Some(TRANSPORT_HINT),
211            Self::PaneNotFound { .. }
212            | Self::ProcessStillRunning { .. }
213            | Self::SpawnFailed { .. }
214            | Self::InvalidRegex { .. }
215            | Self::OwnedSessionLeaseLost { .. }
216            | Self::Collect { .. }
217            | Self::PartialBroadcast { .. }
218            | Self::WaitTimeout { .. } => None,
219        }
220    }
221
222    /// Returns the stable feature identifier when the error variant carries
223    /// one. The identifier is intended for log keys and capability matching,
224    /// not user-facing copy.
225    #[must_use]
226    pub fn feature(&self) -> Option<&str> {
227        match self {
228            Self::Unsupported { feature, .. } => Some(feature),
229            Self::Protocol { .. }
230            | Self::Transport { .. }
231            | Self::Collect { .. }
232            | Self::PartialBroadcast { .. }
233            | Self::WaitTimeout { .. }
234            | Self::PaneNotFound { .. }
235            | Self::ProcessStillRunning { .. }
236            | Self::SpawnFailed { .. }
237            | Self::InvalidRegex { .. }
238            | Self::OwnedSessionLeaseLost { .. } => None,
239        }
240    }
241}
242
243fn map_known_protocol_error(source: rmux_proto::RmuxError) -> RmuxError {
244    match source {
245        rmux_proto::RmuxError::PaneNotFound {
246            session_name,
247            pane_id,
248        } => RmuxError::PaneNotFound {
249            session_name,
250            pane_id,
251        },
252        rmux_proto::RmuxError::ProcessStillRunning => RmuxError::ProcessStillRunning {
253            message: rmux_proto::RmuxError::ProcessStillRunning.to_string(),
254        },
255        rmux_proto::RmuxError::SpawnFailed { message } => RmuxError::SpawnFailed { message },
256        rmux_proto::RmuxError::OwnedSessionLeaseLost { session_name } => {
257            RmuxError::OwnedSessionLeaseLost {
258                message: rmux_proto::RmuxError::OwnedSessionLeaseLost { session_name }.to_string(),
259            }
260        }
261        source => RmuxError::Protocol { source },
262    }
263}
264
265impl From<rmux_proto::RmuxError> for RmuxError {
266    fn from(error: rmux_proto::RmuxError) -> Self {
267        Self::protocol(error)
268    }
269}
270
271impl From<rmux_proto::ErrorResponse> for RmuxError {
272    fn from(response: rmux_proto::ErrorResponse) -> Self {
273        Self::protocol(response.error)
274    }
275}
276
277impl From<io::Error> for RmuxError {
278    fn from(error: io::Error) -> Self {
279        Self::transport("communicate with rmux daemon", error)
280    }
281}
282
283impl From<CollectError> for RmuxError {
284    fn from(error: CollectError) -> Self {
285        Self::collect(error)
286    }
287}
288
289/// Aggregated SDK diagnostics produced by collection-style operations.
290///
291/// The individual diagnostics remain available through [`CollectError::errors`]
292/// and their display output is preserved when the aggregate is formatted,
293/// including per-error `hint:` lines.
294#[derive(Debug, Default)]
295pub struct CollectError {
296    errors: Vec<RmuxError>,
297}
298
299impl CollectError {
300    /// Creates an aggregate from SDK diagnostics.
301    #[must_use]
302    pub fn new(errors: Vec<RmuxError>) -> Self {
303        Self { errors }
304    }
305
306    /// Returns the collected diagnostics.
307    #[must_use]
308    pub fn errors(&self) -> &[RmuxError] {
309        &self.errors
310    }
311
312    /// Returns the number of collected diagnostics.
313    #[must_use]
314    pub fn len(&self) -> usize {
315        self.errors.len()
316    }
317
318    /// Returns `true` when no diagnostics were collected.
319    #[must_use]
320    pub fn is_empty(&self) -> bool {
321        self.errors.is_empty()
322    }
323
324    /// Appends one SDK diagnostic to the aggregate.
325    pub fn push(&mut self, error: RmuxError) {
326        self.errors.push(error);
327    }
328
329    /// Consumes the aggregate and returns the collected diagnostics.
330    #[must_use]
331    pub fn into_errors(self) -> Vec<RmuxError> {
332        self.errors
333    }
334}
335
336impl From<Vec<RmuxError>> for CollectError {
337    fn from(errors: Vec<RmuxError>) -> Self {
338        Self::new(errors)
339    }
340}
341
342impl FromIterator<RmuxError> for CollectError {
343    fn from_iter<T: IntoIterator<Item = RmuxError>>(iter: T) -> Self {
344        Self::new(iter.into_iter().collect())
345    }
346}
347
348impl Extend<RmuxError> for CollectError {
349    fn extend<T: IntoIterator<Item = RmuxError>>(&mut self, iter: T) {
350        self.errors.extend(iter);
351    }
352}
353
354impl fmt::Display for CollectError {
355    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
356        match self.errors.as_slice() {
357            [] => write!(formatter, "no SDK diagnostics were collected"),
358            [error] => {
359                writeln!(formatter, "1 SDK diagnostic collected:")?;
360                write_numbered_error(formatter, 1, error)
361            }
362            errors => {
363                writeln!(formatter, "{} SDK diagnostics collected:", errors.len())?;
364                for (index, error) in errors.iter().enumerate() {
365                    if index > 0 {
366                        writeln!(formatter)?;
367                    }
368                    write_numbered_error(formatter, index + 1, error)?;
369                }
370                Ok(())
371            }
372        }
373    }
374}
375
376impl Error for CollectError {}
377
378impl fmt::Display for RmuxError {
379    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
380        match self {
381            Self::Unsupported { feature, hint } => {
382                write!(formatter, "unsupported feature `{feature}`\nhint: {hint}")
383            }
384            Self::Protocol { source } => {
385                write!(
386                    formatter,
387                    "rmux protocol error: {source}\nhint: {PROTOCOL_HINT}"
388                )
389            }
390            Self::Transport { operation, source } => {
391                write!(
392                    formatter,
393                    "rmux transport error while {operation}: {source}\nhint: {TRANSPORT_HINT}"
394                )
395            }
396            Self::Collect { source } => source.fmt(formatter),
397            Self::PartialBroadcast { source } => source.fmt(formatter),
398            Self::WaitTimeout { source } => source.fmt(formatter),
399            Self::PaneNotFound {
400                session_name,
401                pane_id,
402            } => write!(
403                formatter,
404                "pane id {} was not found in session {session_name}",
405                pane_id
406            ),
407            Self::ProcessStillRunning { message } => {
408                write!(formatter, "pane process still running: {message}")
409            }
410            Self::SpawnFailed { message } => write!(formatter, "pane spawn failed: {message}"),
411            Self::InvalidRegex { pattern, message } => {
412                write!(formatter, "invalid regex `{pattern}`: {message}")
413            }
414            Self::OwnedSessionLeaseLost { message } => {
415                write!(formatter, "owned session lease lost: {message}")
416            }
417        }
418    }
419}
420
421impl Error for RmuxError {
422    fn source(&self) -> Option<&(dyn Error + 'static)> {
423        match self {
424            Self::Unsupported { .. } => None,
425            Self::Protocol { source } => Some(source),
426            Self::Transport { source, .. } => Some(source),
427            Self::Collect { source } => Some(source),
428            Self::PartialBroadcast { source } => Some(source),
429            Self::WaitTimeout { source } => Some(source),
430            Self::PaneNotFound { .. }
431            | Self::ProcessStillRunning { .. }
432            | Self::SpawnFailed { .. }
433            | Self::InvalidRegex { .. }
434            | Self::OwnedSessionLeaseLost { .. } => None,
435        }
436    }
437}
438
439/// SDK result alias parameterised over the SDK facade [`RmuxError`].
440pub type Result<T> = core::result::Result<T, RmuxError>;
441
442trait NonCloneGuard {}
443
444impl<T: Clone> NonCloneGuard for T {}
445impl NonCloneGuard for RmuxError {}
446impl NonCloneGuard for CollectError {}
447
448const _: fn() = sdk_errors_remain_non_clone;
449
450#[allow(dead_code)]
451fn sdk_errors_remain_non_clone() {
452    fn assert_non_clone_guard<T: NonCloneGuard>() {}
453
454    assert_non_clone_guard::<RmuxError>();
455    assert_non_clone_guard::<CollectError>();
456}
457
458fn write_numbered_error(
459    formatter: &mut fmt::Formatter<'_>,
460    index: usize,
461    error: &RmuxError,
462) -> fmt::Result {
463    let rendered = error.to_string();
464    let mut lines = rendered.lines();
465
466    let Some(first) = lines.next() else {
467        return write!(formatter, "{index}. <empty SDK diagnostic>");
468    };
469
470    write!(formatter, "{index}. {first}")?;
471    for line in lines {
472        write!(formatter, "\n   {line}")?;
473    }
474    Ok(())
475}