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::config::ConsoleConfig;
35use crate::console::{
36    DEFAULT_TERMINAL_FIELD_MAX_CHARS, FastMcpConsole, bounded_redacted_rich_fragment,
37    bounded_redacted_terminal_text,
38};
39use crate::diagnostics::RichErrorRenderer;
40
41/// Wraps operations and displays errors beautifully on failure.
42///
43/// `ErrorBoundary` provides a consistent way to handle and display errors
44/// throughout a FastMCP application. Instead of manually calling error
45/// rendering at every error site, wrap operations with an `ErrorBoundary`
46/// and it will automatically handle display on failure.
47///
48/// # Thread Safety
49///
50/// `ErrorBoundary` is thread-safe and can be shared across threads. The
51/// error count is tracked using atomic operations.
52///
53/// # Exit on Error
54///
55/// For CLI applications, you can configure the boundary to exit the process
56/// on error using [`with_exit_on_error`](ErrorBoundary::with_exit_on_error).
57pub struct ErrorBoundary<'a> {
58    console: &'a FastMcpConsole,
59    renderer: RichErrorRenderer,
60    exit_on_error: bool,
61    error_count: AtomicUsize,
62}
63
64impl<'a> ErrorBoundary<'a> {
65    /// Creates a new `ErrorBoundary` with the given console.
66    ///
67    /// The boundary will use the console's theme and context for rendering
68    /// errors.
69    ///
70    /// # Example
71    ///
72    /// ```rust,ignore
73    /// use fastmcp_console::{console, error::ErrorBoundary};
74    ///
75    /// let boundary = ErrorBoundary::new(console());
76    /// ```
77    #[must_use]
78    pub fn new(console: &'a FastMcpConsole) -> Self {
79        Self {
80            console,
81            renderer: RichErrorRenderer::new(),
82            exit_on_error: false,
83            error_count: AtomicUsize::new(0),
84        }
85    }
86
87    /// Creates an `ErrorBoundary` from centralized console configuration.
88    ///
89    /// Error-code, suggestion, and explicit panic-backtrace policy are
90    /// propagated to the boundary's diagnostic renderer.
91    #[must_use]
92    pub fn from_config(console: &'a FastMcpConsole, config: &ConsoleConfig) -> Self {
93        Self {
94            console,
95            renderer: RichErrorRenderer::from_config(config),
96            exit_on_error: false,
97            error_count: AtomicUsize::new(0),
98        }
99    }
100
101    /// Configures the boundary to exit the process on error.
102    ///
103    /// When `exit` is `true`, any error will cause the process to exit
104    /// with code 1 after displaying the error. This is useful for CLI
105    /// applications where errors should terminate the program.
106    ///
107    /// # Example
108    ///
109    /// ```rust,ignore
110    /// let boundary = ErrorBoundary::new(console())
111    ///     .with_exit_on_error(true);
112    ///
113    /// // This will exit the process if load_config() fails
114    /// boundary.wrap(load_config());
115    /// ```
116    #[must_use]
117    pub fn with_exit_on_error(mut self, exit: bool) -> Self {
118        self.exit_on_error = exit;
119        self
120    }
121
122    /// Wraps a `Result`, displaying error if `Err`.
123    ///
124    /// Returns `Some(value)` on success, or `None` on error. The error
125    /// is displayed using the configured console and renderer.
126    ///
127    /// # Type Parameters
128    ///
129    /// * `T` - The success type
130    /// * `E` - The error type, which must be convertible to `McpError`
131    ///
132    /// # Example
133    ///
134    /// ```rust,ignore
135    /// let boundary = ErrorBoundary::new(console());
136    ///
137    /// if let Some(config) = boundary.wrap(load_config()) {
138    ///     // Use config...
139    /// }
140    /// ```
141    pub fn wrap<T, E>(&self, result: Result<T, E>) -> Option<T>
142    where
143        E: Into<McpError>,
144    {
145        match result {
146            Ok(value) => Some(value),
147            Err(e) => {
148                let error = e.into();
149                self.handle_error(&error);
150                None
151            }
152        }
153    }
154
155    /// Wraps a `Result` with a custom context message.
156    ///
157    /// Like [`wrap`](Self::wrap), but displays an additional context message
158    /// before the error to help identify where the error occurred.
159    ///
160    /// # Example
161    ///
162    /// ```rust,ignore
163    /// let boundary = ErrorBoundary::new(console());
164    ///
165    /// let config = boundary.wrap_with_context(
166    ///     load_config(),
167    ///     "Loading server configuration"
168    /// );
169    /// ```
170    pub fn wrap_with_context<T, E>(&self, result: Result<T, E>, context: &str) -> Option<T>
171    where
172        E: Into<McpError>,
173    {
174        match result {
175            Ok(value) => Some(value),
176            Err(e) => {
177                let error = e.into();
178                self.render_context(context);
179                self.handle_error(&error);
180                None
181            }
182        }
183    }
184
185    /// Wraps a `Result`, returning the error if present.
186    ///
187    /// Unlike [`wrap`](Self::wrap), this returns `Result<T, McpError>` instead
188    /// of `Option<T>`. The error is still displayed, but you can also handle
189    /// it programmatically.
190    ///
191    /// # Example
192    ///
193    /// ```rust,ignore
194    /// let boundary = ErrorBoundary::new(console());
195    ///
196    /// match boundary.wrap_result(load_config()) {
197    ///     Ok(config) => { /* use config */ }
198    ///     Err(e) => { /* error was displayed, but we can also log it */ }
199    /// }
200    /// ```
201    pub fn wrap_result<T, E>(&self, result: Result<T, E>) -> Result<T, McpError>
202    where
203        E: Into<McpError>,
204    {
205        match result {
206            Ok(value) => Ok(value),
207            Err(e) => {
208                let error = e.into();
209                self.handle_error(&error);
210                Err(error)
211            }
212        }
213    }
214
215    /// Wraps a `Result` with context, returning the error if present.
216    ///
217    /// Combines [`wrap_with_context`](Self::wrap_with_context) and
218    /// [`wrap_result`](Self::wrap_result) - displays context and error,
219    /// then returns the error for further handling.
220    pub fn wrap_result_with_context<T, E>(
221        &self,
222        result: Result<T, E>,
223        context: &str,
224    ) -> Result<T, McpError>
225    where
226        E: Into<McpError>,
227    {
228        match result {
229            Ok(value) => Ok(value),
230            Err(e) => {
231                let error = e.into();
232                self.render_context(context);
233                self.handle_error(&error);
234                Err(error)
235            }
236        }
237    }
238
239    /// Displays an error directly without wrapping a `Result`.
240    ///
241    /// This is useful when you already have an `McpError` that you want
242    /// to display.
243    ///
244    /// # Example
245    ///
246    /// ```rust,ignore
247    /// let boundary = ErrorBoundary::new(console());
248    /// let error = McpError::internal_error("Something went wrong");
249    /// boundary.display_error(&error);
250    /// ```
251    pub fn display_error(&self, error: &McpError) {
252        self.handle_error(error);
253    }
254
255    /// Gets the total number of errors that have occurred.
256    ///
257    /// This count is incremented each time an error is handled through
258    /// this boundary.
259    #[must_use]
260    pub fn error_count(&self) -> usize {
261        self.error_count.load(Ordering::Relaxed)
262    }
263
264    /// Checks if any errors have occurred.
265    ///
266    /// Returns `true` if at least one error has been handled through
267    /// this boundary.
268    #[must_use]
269    pub fn has_errors(&self) -> bool {
270        self.error_count() > 0
271    }
272
273    /// Resets the error count to zero.
274    ///
275    /// This can be useful when reusing a boundary for multiple operations
276    /// where you want to track errors separately.
277    pub fn reset_count(&self) {
278        self.error_count.store(0, Ordering::Relaxed);
279    }
280
281    fn render_context(&self, context: &str) {
282        if self.console.is_rich() {
283            let context = bounded_redacted_rich_fragment(context, DEFAULT_TERMINAL_FIELD_MAX_CHARS);
284            self.console.print(&format!("[dim]Context: {context}[/]"));
285        } else {
286            let context = bounded_redacted_terminal_text(context, DEFAULT_TERMINAL_FIELD_MAX_CHARS);
287            self.console.print_plain(&format!("Context: {context}"));
288        }
289    }
290
291    /// Handles an error by rendering it and optionally exiting.
292    fn handle_error(&self, error: &McpError) {
293        self.error_count.fetch_add(1, Ordering::Relaxed);
294        self.renderer.render(error, self.console);
295
296        if self.exit_on_error {
297            std::process::exit(1);
298        }
299    }
300}
301
302/// Convenience macro for trying an operation with error display.
303///
304/// If the operation fails, the error is displayed and the macro returns
305/// early from the current function.
306///
307/// # Example
308///
309/// ```rust,ignore
310/// use fastmcp_console::{try_display, error::ErrorBoundary, console};
311///
312/// fn process(boundary: &ErrorBoundary) {
313///     let data = try_display!(boundary, fetch_data());
314///     let result = try_display!(boundary, process(data), "Processing data");
315///     println!("Result: {:?}", result);
316/// }
317/// ```
318#[macro_export]
319macro_rules! try_display {
320    ($boundary:expr, $expr:expr) => {
321        match $boundary.wrap($expr) {
322            Some(v) => v,
323            None => return,
324        }
325    };
326    ($boundary:expr, $expr:expr, $ctx:expr) => {
327        match $boundary.wrap_with_context($expr, $ctx) {
328            Some(v) => v,
329            None => return,
330        }
331    };
332}
333
334/// Convenience macro for trying an operation with error display, returning `Result`.
335///
336/// If the operation fails, the error is displayed and returned as `Err`.
337///
338/// # Example
339///
340/// ```rust,ignore
341/// use fastmcp_console::{try_display_result, error::ErrorBoundary, console};
342///
343/// fn process(boundary: &ErrorBoundary) -> Result<(), McpError> {
344///     let data = try_display_result!(boundary, fetch_data());
345///     let result = try_display_result!(boundary, process(data));
346///     Ok(())
347/// }
348/// ```
349#[macro_export]
350macro_rules! try_display_result {
351    ($boundary:expr, $expr:expr) => {
352        match $boundary.wrap_result($expr) {
353            Ok(v) => v,
354            Err(e) => return Err(e),
355        }
356    };
357    ($boundary:expr, $expr:expr, $ctx:expr) => {
358        match $boundary.wrap_result_with_context($expr, $ctx) {
359            Ok(v) => v,
360            Err(e) => return Err(e),
361        }
362    };
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::testing::TestConsole;
369    use fastmcp_core::McpErrorCode;
370
371    struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
372
373    impl std::io::Write for CaptureWriter {
374        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
375            self.0.lock().unwrap().extend_from_slice(buf);
376            Ok(buf.len())
377        }
378
379        fn flush(&mut self) -> std::io::Result<()> {
380            Ok(())
381        }
382    }
383
384    fn test_console() -> FastMcpConsole {
385        // Create a console with rich output disabled for testing
386        FastMcpConsole::with_enabled(false)
387    }
388
389    #[test]
390    fn test_error_boundary_wrap_success() {
391        let console = test_console();
392        let boundary = ErrorBoundary::new(&console);
393
394        let result: Result<i32, McpError> = Ok(42);
395        assert_eq!(boundary.wrap(result), Some(42));
396        assert_eq!(boundary.error_count(), 0);
397        assert!(!boundary.has_errors());
398    }
399
400    #[test]
401    fn test_error_boundary_wrap_error() {
402        let console = test_console();
403        let boundary = ErrorBoundary::new(&console);
404
405        let result: Result<i32, McpError> = Err(McpError::internal_error("test error"));
406        assert_eq!(boundary.wrap(result), None);
407        assert_eq!(boundary.error_count(), 1);
408        assert!(boundary.has_errors());
409    }
410
411    #[test]
412    fn from_config_propagates_error_code_and_suggestion_policy() {
413        let output = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
414        let console = FastMcpConsole::with_writer(CaptureWriter(output.clone()), false);
415        let mut config = ConsoleConfig::new().without_suggestions();
416        config.show_error_codes = false;
417        let boundary = ErrorBoundary::from_config(&console, &config);
418
419        boundary.display_error(&McpError::new(
420            McpErrorCode::MethodNotFound,
421            "missing method",
422        ));
423        let output = String::from_utf8(output.lock().unwrap().clone()).unwrap();
424        assert!(output.contains("ERROR: missing method"), "{output}");
425        assert!(!output.contains("-32601"), "{output}");
426        assert!(!output.contains("Suggestions"), "{output}");
427        assert_eq!(boundary.error_count(), 1);
428        assert!(!boundary.exit_on_error);
429    }
430
431    #[test]
432    fn test_error_boundary_wrap_with_context() {
433        let console = test_console();
434        let boundary = ErrorBoundary::new(&console);
435
436        let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
437        assert_eq!(boundary.wrap_with_context(result, "Loading config"), None);
438        assert_eq!(boundary.error_count(), 1);
439    }
440
441    #[test]
442    fn test_error_boundary_wrap_result_success() {
443        let console = test_console();
444        let boundary = ErrorBoundary::new(&console);
445
446        let result: Result<i32, McpError> = Ok(42);
447        let wrapped = boundary.wrap_result(result);
448        assert!(wrapped.is_ok());
449        assert_eq!(wrapped.unwrap(), 42);
450        assert_eq!(boundary.error_count(), 0);
451    }
452
453    #[test]
454    fn test_error_boundary_wrap_result_error() {
455        let console = test_console();
456        let boundary = ErrorBoundary::new(&console);
457
458        let result: Result<i32, McpError> = Err(McpError::internal_error("test"));
459        let wrapped = boundary.wrap_result(result);
460        assert!(wrapped.is_err());
461        assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
462        assert_eq!(boundary.error_count(), 1);
463    }
464
465    #[test]
466    fn test_error_boundary_multiple_errors() {
467        let console = test_console();
468        let boundary = ErrorBoundary::new(&console);
469
470        let err1: Result<i32, McpError> = Err(McpError::internal_error("error 1"));
471        let err2: Result<i32, McpError> = Err(McpError::parse_error("error 2"));
472        let err3: Result<i32, McpError> = Err(McpError::method_not_found("test"));
473
474        boundary.wrap(err1);
475        boundary.wrap(err2);
476        boundary.wrap(err3);
477
478        assert_eq!(boundary.error_count(), 3);
479    }
480
481    #[test]
482    fn test_error_boundary_reset_count() {
483        let console = test_console();
484        let boundary = ErrorBoundary::new(&console);
485
486        let err: Result<i32, McpError> = Err(McpError::internal_error("test"));
487        boundary.wrap(err);
488        assert_eq!(boundary.error_count(), 1);
489
490        boundary.reset_count();
491        assert_eq!(boundary.error_count(), 0);
492        assert!(!boundary.has_errors());
493    }
494
495    #[test]
496    fn test_error_boundary_display_error() {
497        let console = test_console();
498        let boundary = ErrorBoundary::new(&console);
499
500        let error = McpError::internal_error("direct display");
501        boundary.display_error(&error);
502
503        assert_eq!(boundary.error_count(), 1);
504    }
505
506    #[test]
507    fn test_error_boundary_mixed_results() {
508        let console = test_console();
509        let boundary = ErrorBoundary::new(&console);
510
511        // Some successes
512        let ok1: Result<i32, McpError> = Ok(1);
513        let ok2: Result<i32, McpError> = Ok(2);
514
515        // Some failures
516        let err1: Result<i32, McpError> = Err(McpError::internal_error("e1"));
517        let err2: Result<i32, McpError> = Err(McpError::internal_error("e2"));
518
519        assert_eq!(boundary.wrap(ok1), Some(1));
520        assert_eq!(boundary.wrap(err1), None);
521        assert_eq!(boundary.wrap(ok2), Some(2));
522        assert_eq!(boundary.wrap(err2), None);
523
524        // Only the errors should be counted
525        assert_eq!(boundary.error_count(), 2);
526    }
527
528    #[test]
529    fn test_error_boundary_from_other_error_types() {
530        let console = test_console();
531        let boundary = ErrorBoundary::new(&console);
532
533        // serde_json::Error can be converted to McpError
534        let json_result: Result<serde_json::Value, serde_json::Error> =
535            serde_json::from_str("invalid json");
536
537        // The error type must implement Into<McpError>
538        let mcp_result = json_result.map_err(McpError::from);
539        assert_eq!(boundary.wrap(mcp_result), None);
540        assert_eq!(boundary.error_count(), 1);
541    }
542
543    #[test]
544    fn test_with_exit_on_error_builder_flag() {
545        let console = test_console();
546
547        // Builder should be chainable and preserve behavior when disabled.
548        let boundary = ErrorBoundary::new(&console).with_exit_on_error(false);
549        let result: Result<i32, McpError> = Ok(7);
550        assert_eq!(boundary.wrap(result), Some(7));
551        assert_eq!(boundary.error_count(), 0);
552    }
553
554    #[test]
555    fn test_wrap_with_context_success_path() {
556        let console = test_console();
557        let boundary = ErrorBoundary::new(&console);
558
559        let result: Result<&str, McpError> = Ok("ok");
560        assert_eq!(
561            boundary.wrap_with_context(result, "unused context"),
562            Some("ok")
563        );
564        assert_eq!(boundary.error_count(), 0);
565    }
566
567    #[test]
568    fn test_wrap_result_with_context_success_and_error_paths() {
569        let console = test_console();
570        let boundary = ErrorBoundary::new(&console);
571
572        let ok: Result<i32, McpError> = Ok(123);
573        let wrapped_ok = boundary.wrap_result_with_context(ok, "computing value");
574        assert!(wrapped_ok.is_ok());
575        assert_eq!(wrapped_ok.unwrap(), 123);
576        assert_eq!(boundary.error_count(), 0);
577
578        let err: Result<i32, McpError> = Err(McpError::internal_error("boom"));
579        let wrapped = boundary.wrap_result_with_context(err, "computing value");
580        assert!(wrapped.is_err());
581        assert_eq!(wrapped.unwrap_err().code, McpErrorCode::InternalError);
582        assert_eq!(boundary.error_count(), 1);
583    }
584
585    #[test]
586    fn plain_context_is_bounded_redacted_and_terminal_safe() {
587        let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
588        let console = FastMcpConsole::with_writer(CaptureWriter(buffer.clone()), false);
589        let boundary = ErrorBoundary::new(&console);
590        let context = format!(
591            "\u{1b}\u{202e}[bold red]owned[/] access_token=context-secret-canary {}",
592            "x".repeat(20_000)
593        );
594        let result: Result<(), McpError> = Err(McpError::internal_error("failure"));
595
596        assert_eq!(boundary.wrap_with_context(result, &context), None);
597        let output = String::from_utf8(buffer.lock().unwrap().clone()).unwrap();
598        assert!(output.contains("Context:"), "{output}");
599        assert!(output.contains("[REDACTED]"), "{output}");
600        assert!(!output.contains("context-secret-canary"), "{output}");
601        assert!(!output.contains('\u{1b}'), "{output}");
602        assert!(!output.contains('\u{202e}'), "{output}");
603        assert!(output.contains("\\u{1b}"), "{output}");
604        assert!(
605            output.chars().count() <= 600,
606            "context output was unbounded"
607        );
608    }
609
610    #[test]
611    fn rich_context_escapes_markup_and_redacts_credentials() {
612        let console = TestConsole::new_rich();
613        let boundary = ErrorBoundary::new(console.console());
614        let result: Result<(), McpError> = Err(McpError::internal_error("failure"));
615
616        assert!(
617            boundary
618                .wrap_result_with_context(
619                    result,
620                    "[bold red]owned[/] Authorization: Bearer rich-context-secret"
621                )
622                .is_err()
623        );
624        let output = console.output_string();
625        assert!(output.contains("[bold red]owned[/]"), "{output}");
626        assert!(output.contains("[REDACTED]"), "{output}");
627        assert!(!output.contains("rich-context-secret"), "{output}");
628    }
629}