Skip to main content

fastmcp_console/error/
boundary.rs

1//! ErrorBoundary wrapper for automatic error display.
2//!
3//! The [`ErrorBoundary`] type wraps operations and automatically catches
4//! and beautifully displays errors throughout FastMCP. This ensures consistent
5//! error presentation without manual render calls everywhere.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use fastmcp_console::error::ErrorBoundary;
11//! use fastmcp_console::console;
12//!
13//! let boundary = ErrorBoundary::new(console());
14//!
15//! // Simple usage - returns Option<T>
16//! let config = boundary.wrap(load_config());
17//!
18//! // With context message
19//! let config = boundary.wrap_with_context(
20//!     load_config(),
21//!     "Loading server configuration"
22//! );
23//!
24//! // Check if any errors occurred
25//! if boundary.has_errors() {
26//!     eprintln!("Encountered {} errors", boundary.error_count());
27//! }
28//! ```
29
30use std::sync::atomic::{AtomicUsize, Ordering};
31
32use fastmcp_core::McpError;
33
34use crate::console::FastMcpConsole;
35use crate::diagnostics::RichErrorRenderer;
36
37/// Wraps operations and displays errors beautifully on failure.
38///
39/// `ErrorBoundary` provides a consistent way to handle and display errors
40/// throughout a FastMCP application. Instead of manually calling error
41/// rendering at every error site, wrap operations with an `ErrorBoundary`
42/// and it will automatically handle display on failure.
43///
44/// # Thread Safety
45///
46/// `ErrorBoundary` is thread-safe and can be shared across threads. The
47/// error count is tracked using atomic operations.
48///
49/// # Exit on Error
50///
51/// For CLI applications, you can configure the boundary to exit the process
52/// on error using [`with_exit_on_error`](ErrorBoundary::with_exit_on_error).
53pub struct ErrorBoundary<'a> {
54    console: &'a FastMcpConsole,
55    renderer: RichErrorRenderer,
56    exit_on_error: bool,
57    error_count: AtomicUsize,
58}
59
60impl<'a> ErrorBoundary<'a> {
61    /// Creates a new `ErrorBoundary` with the given console.
62    ///
63    /// The boundary will use the console's theme and context for rendering
64    /// errors.
65    ///
66    /// # Example
67    ///
68    /// ```rust,ignore
69    /// use fastmcp_console::{console, error::ErrorBoundary};
70    ///
71    /// let boundary = ErrorBoundary::new(console());
72    /// ```
73    #[must_use]
74    pub fn new(console: &'a FastMcpConsole) -> Self {
75        Self {
76            console,
77            renderer: RichErrorRenderer::new(),
78            exit_on_error: false,
79            error_count: AtomicUsize::new(0),
80        }
81    }
82
83    /// Configures the boundary to exit the process on error.
84    ///
85    /// When `exit` is `true`, any error will cause the process to exit
86    /// with code 1 after displaying the error. This is useful for CLI
87    /// applications where errors should terminate the program.
88    ///
89    /// # Example
90    ///
91    /// ```rust,ignore
92    /// let boundary = ErrorBoundary::new(console())
93    ///     .with_exit_on_error(true);
94    ///
95    /// // This will exit the process if load_config() fails
96    /// boundary.wrap(load_config());
97    /// ```
98    #[must_use]
99    pub fn with_exit_on_error(mut self, exit: bool) -> Self {
100        self.exit_on_error = exit;
101        self
102    }
103
104    /// Wraps a `Result`, displaying error if `Err`.
105    ///
106    /// Returns `Some(value)` on success, or `None` on error. The error
107    /// is displayed using the configured console and renderer.
108    ///
109    /// # Type Parameters
110    ///
111    /// * `T` - The success type
112    /// * `E` - The error type, which must be convertible to `McpError`
113    ///
114    /// # Example
115    ///
116    /// ```rust,ignore
117    /// let boundary = ErrorBoundary::new(console());
118    ///
119    /// if let Some(config) = boundary.wrap(load_config()) {
120    ///     // Use config...
121    /// }
122    /// ```
123    pub fn wrap<T, E>(&self, result: Result<T, E>) -> Option<T>
124    where
125        E: Into<McpError>,
126    {
127        match result {
128            Ok(value) => Some(value),
129            Err(e) => {
130                let error = e.into();
131                self.handle_error(&error);
132                None
133            }
134        }
135    }
136
137    /// Wraps a `Result` with a custom context message.
138    ///
139    /// Like [`wrap`](Self::wrap), but displays an additional context message
140    /// before the error to help identify where the error occurred.
141    ///
142    /// # Example
143    ///
144    /// ```rust,ignore
145    /// let boundary = ErrorBoundary::new(console());
146    ///
147    /// let config = boundary.wrap_with_context(
148    ///     load_config(),
149    ///     "Loading server configuration"
150    /// );
151    /// ```
152    pub fn wrap_with_context<T, E>(&self, result: Result<T, E>, context: &str) -> Option<T>
153    where
154        E: Into<McpError>,
155    {
156        match result {
157            Ok(value) => Some(value),
158            Err(e) => {
159                let error = e.into();
160                self.console.print(&format!("[dim]Context: {}[/]", context));
161                self.handle_error(&error);
162                None
163            }
164        }
165    }
166
167    /// Wraps a `Result`, returning the error if present.
168    ///
169    /// Unlike [`wrap`](Self::wrap), this returns `Result<T, McpError>` instead
170    /// of `Option<T>`. The error is still displayed, but you can also handle
171    /// it programmatically.
172    ///
173    /// # Example
174    ///
175    /// ```rust,ignore
176    /// let boundary = ErrorBoundary::new(console());
177    ///
178    /// match boundary.wrap_result(load_config()) {
179    ///     Ok(config) => { /* use config */ }
180    ///     Err(e) => { /* error was displayed, but we can also log it */ }
181    /// }
182    /// ```
183    pub fn wrap_result<T, E>(&self, result: Result<T, E>) -> Result<T, McpError>
184    where
185        E: Into<McpError>,
186    {
187        match result {
188            Ok(value) => Ok(value),
189            Err(e) => {
190                let error = e.into();
191                self.handle_error(&error);
192                Err(error)
193            }
194        }
195    }
196
197    /// Wraps a `Result` with context, returning the error if present.
198    ///
199    /// Combines [`wrap_with_context`](Self::wrap_with_context) and
200    /// [`wrap_result`](Self::wrap_result) - displays context and error,
201    /// then returns the error for further handling.
202    pub fn wrap_result_with_context<T, E>(
203        &self,
204        result: Result<T, E>,
205        context: &str,
206    ) -> Result<T, McpError>
207    where
208        E: Into<McpError>,
209    {
210        match result {
211            Ok(value) => Ok(value),
212            Err(e) => {
213                let error = e.into();
214                self.console.print(&format!("[dim]Context: {}[/]", context));
215                self.handle_error(&error);
216                Err(error)
217            }
218        }
219    }
220
221    /// Displays an error directly without wrapping a `Result`.
222    ///
223    /// This is useful when you already have an `McpError` that you want
224    /// to display.
225    ///
226    /// # Example
227    ///
228    /// ```rust,ignore
229    /// let boundary = ErrorBoundary::new(console());
230    /// let error = McpError::internal_error("Something went wrong");
231    /// boundary.display_error(&error);
232    /// ```
233    pub fn display_error(&self, error: &McpError) {
234        self.handle_error(error);
235    }
236
237    /// Gets the total number of errors that have occurred.
238    ///
239    /// This count is incremented each time an error is handled through
240    /// this boundary.
241    #[must_use]
242    pub fn error_count(&self) -> usize {
243        self.error_count.load(Ordering::Relaxed)
244    }
245
246    /// Checks if any errors have occurred.
247    ///
248    /// Returns `true` if at least one error has been handled through
249    /// this boundary.
250    #[must_use]
251    pub fn has_errors(&self) -> bool {
252        self.error_count() > 0
253    }
254
255    /// Resets the error count to zero.
256    ///
257    /// This can be useful when reusing a boundary for multiple operations
258    /// where you want to track errors separately.
259    pub fn reset_count(&self) {
260        self.error_count.store(0, Ordering::Relaxed);
261    }
262
263    /// Handles an error by rendering it and optionally exiting.
264    fn handle_error(&self, error: &McpError) {
265        self.error_count.fetch_add(1, Ordering::Relaxed);
266        self.renderer.render(error, self.console);
267
268        if self.exit_on_error {
269            std::process::exit(1);
270        }
271    }
272}
273
274/// Convenience macro for trying an operation with error display.
275///
276/// If the operation fails, the error is displayed and the macro returns
277/// early from the current function.
278///
279/// # Example
280///
281/// ```rust,ignore
282/// use fastmcp_console::{try_display, error::ErrorBoundary, console};
283///
284/// fn process(boundary: &ErrorBoundary) {
285///     let data = try_display!(boundary, fetch_data());
286///     let result = try_display!(boundary, process(data), "Processing data");
287///     println!("Result: {:?}", result);
288/// }
289/// ```
290#[macro_export]
291macro_rules! try_display {
292    ($boundary:expr, $expr:expr) => {
293        match $boundary.wrap($expr) {
294            Some(v) => v,
295            None => return,
296        }
297    };
298    ($boundary:expr, $expr:expr, $ctx:expr) => {
299        match $boundary.wrap_with_context($expr, $ctx) {
300            Some(v) => v,
301            None => return,
302        }
303    };
304}
305
306/// Convenience macro for trying an operation with error display, returning `Result`.
307///
308/// If the operation fails, the error is displayed and returned as `Err`.
309///
310/// # Example
311///
312/// ```rust,ignore
313/// use fastmcp_console::{try_display_result, error::ErrorBoundary, console};
314///
315/// fn process(boundary: &ErrorBoundary) -> Result<(), McpError> {
316///     let data = try_display_result!(boundary, fetch_data());
317///     let result = try_display_result!(boundary, process(data));
318///     Ok(())
319/// }
320/// ```
321#[macro_export]
322macro_rules! try_display_result {
323    ($boundary:expr, $expr:expr) => {
324        match $boundary.wrap_result($expr) {
325            Ok(v) => v,
326            Err(e) => return Err(e),
327        }
328    };
329    ($boundary:expr, $expr:expr, $ctx:expr) => {
330        match $boundary.wrap_result_with_context($expr, $ctx) {
331            Ok(v) => v,
332            Err(e) => return Err(e),
333        }
334    };
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340    use fastmcp_core::McpErrorCode;
341
342    fn test_console() -> FastMcpConsole {
343        // Create a console with rich output disabled for testing
344        FastMcpConsole::with_enabled(false)
345    }
346
347    #[test]
348    fn test_error_boundary_wrap_success() {
349        let console = test_console();
350        let boundary = ErrorBoundary::new(&console);
351
352        let result: Result<i32, McpError> = Ok(42);
353        assert_eq!(boundary.wrap(result), Some(42));
354        assert_eq!(boundary.error_count(), 0);
355        assert!(!boundary.has_errors());
356    }
357
358    #[test]
359    fn test_error_boundary_wrap_error() {
360        let console = test_console();
361        let boundary = ErrorBoundary::new(&console);
362
363        let result: Result<i32, McpError> = Err(McpError::internal_error("test error"));
364        assert_eq!(boundary.wrap(result), None);
365        assert_eq!(boundary.error_count(), 1);
366        assert!(boundary.has_errors());
367    }
368
369    #[test]
370    fn test_error_boundary_wrap_with_context() {
371        let console = test_console();
372        let boundary = ErrorBoundary::new(&console);
373
374        let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
375        assert_eq!(boundary.wrap_with_context(result, "Loading config"), None);
376        assert_eq!(boundary.error_count(), 1);
377    }
378
379    #[test]
380    fn test_error_boundary_wrap_result_success() {
381        let console = test_console();
382        let boundary = ErrorBoundary::new(&console);
383
384        let result: Result<i32, McpError> = Ok(42);
385        let wrapped = boundary.wrap_result(result);
386        assert!(wrapped.is_ok());
387        assert_eq!(wrapped.unwrap(), 42);
388        assert_eq!(boundary.error_count(), 0);
389    }
390
391    #[test]
392    fn test_error_boundary_wrap_result_error() {
393        let console = test_console();
394        let boundary = ErrorBoundary::new(&console);
395
396        let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
397        let wrapped = boundary.wrap_result(result);
398        assert!(wrapped.is_err());
399        assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
400        assert_eq!(boundary.error_count(), 1);
401    }
402
403    #[test]
404    fn test_error_boundary_multiple_errors() {
405        let console = test_console();
406        let boundary = ErrorBoundary::new(&console);
407
408        let err1: Result<i32, McpError> = Err(McpError::internal_error("error 1"));
409        let err2: Result<i32, McpError> = Err(McpError::parse_error("error 2"));
410        let err3: Result<i32, McpError> = Err(McpError::method_not_found("test"));
411
412        boundary.wrap(err1);
413        boundary.wrap(err2);
414        boundary.wrap(err3);
415
416        assert_eq!(boundary.error_count(), 3);
417    }
418
419    #[test]
420    fn test_error_boundary_reset_count() {
421        let console = test_console();
422        let boundary = ErrorBoundary::new(&console);
423
424        let err: Result<i32, McpError> = Err(McpError::internal_error("test"));
425        boundary.wrap(err);
426        assert_eq!(boundary.error_count(), 1);
427
428        boundary.reset_count();
429        assert_eq!(boundary.error_count(), 0);
430        assert!(!boundary.has_errors());
431    }
432
433    #[test]
434    fn test_error_boundary_display_error() {
435        let console = test_console();
436        let boundary = ErrorBoundary::new(&console);
437
438        let error = McpError::internal_error("direct display");
439        boundary.display_error(&error);
440
441        assert_eq!(boundary.error_count(), 1);
442    }
443
444    #[test]
445    fn test_error_boundary_mixed_results() {
446        let console = test_console();
447        let boundary = ErrorBoundary::new(&console);
448
449        // Some successes
450        let ok1: Result<i32, McpError> = Ok(1);
451        let ok2: Result<i32, McpError> = Ok(2);
452
453        // Some failures
454        let err1: Result<i32, McpError> = Err(McpError::internal_error("e1"));
455        let err2: Result<i32, McpError> = Err(McpError::internal_error("e2"));
456
457        assert_eq!(boundary.wrap(ok1), Some(1));
458        assert_eq!(boundary.wrap(err1), None);
459        assert_eq!(boundary.wrap(ok2), Some(2));
460        assert_eq!(boundary.wrap(err2), None);
461
462        // Only the errors should be counted
463        assert_eq!(boundary.error_count(), 2);
464    }
465
466    #[test]
467    fn test_error_boundary_from_other_error_types() {
468        let console = test_console();
469        let boundary = ErrorBoundary::new(&console);
470
471        // serde_json::Error can be converted to McpError
472        let json_result: Result<serde_json::Value, serde_json::Error> =
473            serde_json::from_str("invalid json");
474
475        // The error type must implement Into<McpError>
476        let mcp_result = json_result.map_err(McpError::from);
477        assert_eq!(boundary.wrap(mcp_result), None);
478        assert_eq!(boundary.error_count(), 1);
479    }
480
481    #[test]
482    fn test_with_exit_on_error_builder_flag() {
483        let console = test_console();
484
485        // Builder should be chainable and preserve behavior when disabled.
486        let boundary = ErrorBoundary::new(&console).with_exit_on_error(false);
487        let result: Result<i32, McpError> = Ok(7);
488        assert_eq!(boundary.wrap(result), Some(7));
489        assert_eq!(boundary.error_count(), 0);
490    }
491
492    #[test]
493    fn test_wrap_with_context_success_path() {
494        let console = test_console();
495        let boundary = ErrorBoundary::new(&console);
496
497        let result: Result<&str, McpError> = Ok("ok");
498        assert_eq!(
499            boundary.wrap_with_context(result, "unused context"),
500            Some("ok")
501        );
502        assert_eq!(boundary.error_count(), 0);
503    }
504
505    #[test]
506    fn test_wrap_result_with_context_success_and_error_paths() {
507        let console = test_console();
508        let boundary = ErrorBoundary::new(&console);
509
510        let ok: Result<i32, McpError> = Ok(123);
511        let wrapped_ok = boundary.wrap_result_with_context(ok, "computing value");
512        assert!(wrapped_ok.is_ok());
513        assert_eq!(wrapped_ok.unwrap(), 123);
514        assert_eq!(boundary.error_count(), 0);
515
516        let err: Result<i32, McpError> = Err(McpError::internal_error("boom"));
517        let wrapped = boundary.wrap_result_with_context(err, "computing value");
518        assert!(wrapped.is_err());
519        assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
520        assert_eq!(boundary.error_count(), 1);
521    }
522}