Skip to main content

fastmcp_console/
console.rs

1//! Console wrapper for rich stderr output.
2//!
3//! `FastMcpConsole` is the core output surface for fastmcp-console. It wraps
4//! a `rich_rust::Console` configured to write to stderr, and it automatically
5//! falls back to plain text when running in agent contexts.
6//!
7//! # Quick Example
8//!
9//! ```rust,ignore
10//! use fastmcp_console::console::FastMcpConsole;
11//!
12//! let console = FastMcpConsole::new();
13//! console.rule(Some("FastMCP Console"));
14//! console.print("Ready.");
15//! ```
16
17use crate::theme::FastMcpTheme;
18use rich_rust::prelude::*;
19use rich_rust::renderables::Renderable;
20use std::io::{self, Write};
21use std::sync::{Mutex, OnceLock};
22
23/// FastMCP console for rich output to stderr.
24///
25/// This type centralizes rich-vs-plain output behavior and exposes
26/// convenience methods for printing tables, panels, and styled text.
27///
28/// # Example
29///
30/// ```rust,ignore
31/// use fastmcp_console::console::FastMcpConsole;
32/// use rich_rust::prelude::Style;
33///
34/// let console = FastMcpConsole::new();
35/// console.print_styled("Server started", Style::new().bold());
36/// ```
37pub struct FastMcpConsole {
38    inner: Mutex<Console>,
39    enabled: bool,
40    theme: &'static FastMcpTheme,
41}
42
43impl FastMcpConsole {
44    /// Create with automatic detection
45    #[must_use]
46    pub fn new() -> Self {
47        let enabled = crate::detection::should_enable_rich();
48        Self::with_enabled(enabled)
49    }
50
51    /// Create with explicit enable/disable
52    #[must_use]
53    pub fn with_enabled(enabled: bool) -> Self {
54        let inner = if enabled {
55            Console::builder()
56                .file(Box::new(io::stderr()))
57                .force_terminal(true)
58                .markup(true)
59                .emoji(true)
60                .build()
61        } else {
62            Console::builder()
63                .file(Box::new(io::stderr()))
64                .no_color()
65                .markup(false)
66                .emoji(false)
67                .build()
68        };
69
70        Self {
71            inner: Mutex::new(inner),
72            enabled,
73            theme: crate::theme::theme(),
74        }
75    }
76
77    /// Create with custom writer (for testing)
78    #[must_use]
79    pub fn with_writer<W: Write + Send + 'static>(writer: W, enabled: bool) -> Self {
80        let mut builder = Console::builder()
81            .file(Box::new(writer))
82            .markup(enabled)
83            .emoji(enabled);
84
85        if !enabled {
86            builder = builder.no_color();
87        }
88
89        let inner = if enabled {
90            builder.force_terminal(true).build()
91        } else {
92            builder.build()
93        };
94
95        Self {
96            inner: Mutex::new(inner),
97            enabled,
98            theme: crate::theme::theme(),
99        }
100    }
101
102    // ─────────────────────────────────────────────────
103    // State Queries
104    // ─────────────────────────────────────────────────
105
106    /// Check if rich output is enabled.
107    pub fn is_rich(&self) -> bool {
108        self.enabled
109    }
110
111    /// Get the theme used for standard styling.
112    pub fn theme(&self) -> &FastMcpTheme {
113        self.theme
114    }
115
116    /// Get terminal width (or default 80).
117    pub fn width(&self) -> usize {
118        if let Ok(c) = self.inner.lock() {
119            c.width()
120        } else {
121            80
122        }
123    }
124
125    /// Get terminal height (or default 24).
126    pub fn height(&self) -> usize {
127        if let Ok(c) = self.inner.lock() {
128            c.height()
129        } else {
130            24
131        }
132    }
133
134    // ─────────────────────────────────────────────────
135    // Output Methods
136    // ─────────────────────────────────────────────────
137
138    /// Print styled text (auto-detects markup).
139    pub fn print(&self, content: &str) {
140        if self.enabled {
141            if let Ok(console) = self.inner.lock() {
142                console.print(content);
143            }
144        } else {
145            eprintln!("{}", strip_markup(content));
146        }
147    }
148
149    /// Print plain text (no markup processing ever).
150    pub fn print_plain(&self, text: &str) {
151        if let Ok(console) = self.inner.lock() {
152            // Escape brackets with backslash (rich markup escape sequence)
153            // to prevent markup interpretation, then print through the console's
154            // configured writer
155            let escaped = text.replace('[', "\\[").replace(']', "\\]");
156            console.print(&escaped);
157        } else {
158            eprintln!("{text}");
159        }
160    }
161
162    /// Print a renderable.
163    pub fn render<R: Renderable>(&self, renderable: &R) {
164        if self.enabled {
165            if let Ok(console) = self.inner.lock() {
166                console.print_renderable(renderable);
167            }
168        } else {
169            // Plain fallback: caller should provide alternative or we log a placeholder
170            eprintln!("[Complex Output]");
171        }
172    }
173
174    /// Print a renderable with plain-text fallback closure.
175    pub fn render_or<F>(&self, render_op: F, plain_fallback: &str)
176    where
177        F: FnOnce(&Console),
178    {
179        if self.enabled {
180            if let Ok(console) = self.inner.lock() {
181                render_op(&console);
182            }
183        } else {
184            eprintln!("{plain_fallback}");
185        }
186    }
187
188    // ─────────────────────────────────────────────────
189    // Convenience Methods
190    // ─────────────────────────────────────────────────
191
192    /// Print a horizontal rule.
193    pub fn rule(&self, title: Option<&str>) {
194        if self.enabled {
195            if let Ok(console) = self.inner.lock() {
196                match title {
197                    Some(t) => console.print_renderable(
198                        &Rule::with_title(t).style(self.theme.border_style.clone()),
199                    ),
200                    None => console
201                        .print_renderable(&Rule::new().style(self.theme.border_style.clone())),
202                }
203            }
204        } else {
205            match title {
206                Some(t) => eprintln!("--- {t} ---"),
207                None => eprintln!("---"),
208            }
209        }
210    }
211
212    /// Print a blank line.
213    pub fn newline(&self) {
214        eprintln!();
215    }
216
217    /// Print styled text with a specific style.
218    pub fn print_styled(&self, text: &str, style: Style) {
219        if self.enabled {
220            if let Ok(console) = self.inner.lock() {
221                console.print_styled(text, style);
222            }
223        } else {
224            eprintln!("{text}");
225        }
226    }
227
228    /// Print a table (with plain fallback).
229    pub fn print_table(&self, table: &Table, plain_fallback: &str) {
230        if self.enabled {
231            if let Ok(console) = self.inner.lock() {
232                console.print_renderable(table);
233            }
234        } else {
235            eprintln!("{plain_fallback}");
236        }
237    }
238
239    /// Print a panel (with plain fallback).
240    pub fn print_panel(&self, panel: &Panel, plain_fallback: &str) {
241        if self.enabled {
242            if let Ok(console) = self.inner.lock() {
243                console.print_renderable(panel);
244            }
245        } else {
246            eprintln!("{plain_fallback}");
247        }
248    }
249}
250
251impl Default for FastMcpConsole {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257// ─────────────────────────────────────────────────────────
258// Global Console Accessor
259// ─────────────────────────────────────────────────────────
260
261static CONSOLE: OnceLock<FastMcpConsole> = OnceLock::new();
262
263/// Get the global FastMCP console instance.
264///
265/// # Example
266///
267/// ```rust,ignore
268/// let console = fastmcp_console::console::console();
269/// console.print("Hello from global console");
270/// ```
271#[must_use]
272pub fn console() -> &'static FastMcpConsole {
273    CONSOLE.get_or_init(FastMcpConsole::new)
274}
275
276/// Initialize the global console with specific settings.
277///
278/// Must be called before any output; returns error if already initialized.
279///
280/// # Example
281///
282/// ```rust,ignore
283/// use fastmcp_console::console::init_console;
284///
285/// init_console(false).expect("console already initialized");
286/// ```
287pub fn init_console(enabled: bool) -> Result<(), &'static str> {
288    CONSOLE
289        .set(FastMcpConsole::with_enabled(enabled))
290        .map_err(|_| "Console already initialized")
291}
292
293// ─────────────────────────────────────────────────────────
294// Helpers
295// ─────────────────────────────────────────────────────────
296
297/// Strip markup tags from text (for plain output).
298///
299/// Handles escaped brackets (`[[` -> `[`, `\\[` -> `[`, `\\]` -> `]`) and strips valid tags (`[...]`).
300#[must_use]
301pub fn strip_markup(text: &str) -> String {
302    let mut out = String::with_capacity(text.len());
303    let mut chars = text.chars().peekable();
304
305    while let Some(ch) = chars.next() {
306        match ch {
307            '\\' => {
308                // Rich uses backslash escaping for literal brackets. Preserve those for plain output.
309                if let Some(next) = chars.peek().copied() {
310                    if next == '[' || next == ']' || next == '\\' {
311                        out.push(next);
312                        chars.next();
313                    } else {
314                        out.push('\\');
315                    }
316                } else {
317                    out.push('\\');
318                }
319            }
320            '[' => {
321                // Check for escaped bracket [[
322                if let Some('[') = chars.peek() {
323                    out.push('[');
324                    chars.next(); // Consume the second [
325                } else {
326                    // It's a tag start, skip until ]
327                    // Note: This is a simple skippper; it doesn't handle nested brackets
328                    // or quoted strings inside tags, but covers standard style tags.
329                    for c in chars.by_ref() {
330                        if c == ']' {
331                            break;
332                        }
333                    }
334                }
335            }
336            _ => out.push(ch),
337        }
338    }
339
340    out
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use std::io::Write;
347    use std::sync::{Arc, Mutex};
348
349    #[derive(Clone, Debug)]
350    struct SharedWriter {
351        buf: Arc<Mutex<Vec<u8>>>,
352    }
353
354    impl SharedWriter {
355        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
356            let buf = Arc::new(Mutex::new(Vec::new()));
357            (
358                Self {
359                    buf: Arc::clone(&buf),
360                },
361                buf,
362            )
363        }
364    }
365
366    impl Write for SharedWriter {
367        fn write(&mut self, input: &[u8]) -> std::io::Result<usize> {
368            if let Ok(mut guard) = self.buf.lock() {
369                guard.extend_from_slice(input);
370            }
371            Ok(input.len())
372        }
373
374        fn flush(&mut self) -> std::io::Result<()> {
375            Ok(())
376        }
377    }
378
379    #[test]
380    fn test_strip_markup_simple() {
381        assert_eq!(strip_markup("[bold]Hello[/]"), "Hello");
382    }
383
384    #[test]
385    fn test_strip_markup_nested() {
386        assert_eq!(strip_markup("[bold][red]Error[/][/]"), "Error");
387    }
388
389    #[test]
390    fn test_strip_markup_multiple_tags() {
391        assert_eq!(
392            strip_markup("[green]✓[/] Success [dim](100ms)[/]"),
393            "✓ Success (100ms)"
394        );
395    }
396
397    #[test]
398    fn test_strip_markup_no_tags() {
399        assert_eq!(strip_markup("Plain text"), "Plain text");
400    }
401
402    #[test]
403    fn test_strip_markup_empty() {
404        assert_eq!(strip_markup(""), "");
405    }
406
407    #[test]
408    fn test_strip_markup_only_tags() {
409        assert_eq!(strip_markup("[bold][/]"), "");
410    }
411
412    #[test]
413    fn test_strip_markup_preserves_unicode() {
414        assert_eq!(strip_markup("[info]⚡ Fast[/]"), "⚡ Fast");
415    }
416
417    #[test]
418    fn test_strip_markup_preserves_backslash_escaped_brackets() {
419        assert_eq!(
420            strip_markup(r"tools/list \[OK\] 12ms"),
421            "tools/list [OK] 12ms"
422        );
423        assert_eq!(strip_markup(r"\[x\]"), "[x]");
424        assert_eq!(strip_markup(r"\\[bold]x[/]"), r"\x");
425    }
426
427    #[test]
428    fn test_strip_markup_double_bracket_escape() {
429        assert_eq!(strip_markup("[[literal]]"), "[literal]]");
430    }
431
432    #[test]
433    fn test_console_with_enabled_true() {
434        let console = FastMcpConsole::with_enabled(true);
435        assert!(console.is_rich());
436    }
437
438    #[test]
439    fn test_console_with_enabled_false() {
440        let console = FastMcpConsole::with_enabled(false);
441        assert!(!console.is_rich());
442    }
443
444    #[test]
445    fn test_console_theme_access() {
446        let console = FastMcpConsole::with_enabled(false);
447        let theme = console.theme();
448        // Verify theme is accessible
449        assert_eq!(theme.primary.triplet.map(|tr| tr.blue), Some(255));
450    }
451
452    #[test]
453    fn test_console_dimensions_default() {
454        let console = FastMcpConsole::with_enabled(false);
455        // Non-TTY should return defaults
456        assert!(console.width() > 0);
457        assert!(console.height() > 0);
458    }
459
460    #[test]
461    fn test_with_writer_print_and_print_plain_paths() {
462        let (writer, captured) = SharedWriter::new();
463        let console = FastMcpConsole::with_writer(writer, true);
464
465        console.print("[bold]Hello[/]");
466        console.print_plain("[literal]");
467
468        let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
469            .unwrap_or_default();
470        assert!(output.contains("Hello"));
471        assert!(output.contains("literal"));
472    }
473
474    #[test]
475    fn test_render_and_convenience_methods_in_rich_mode() {
476        let (writer, captured) = SharedWriter::new();
477        let console = FastMcpConsole::with_writer(writer, true);
478
479        let mut table = Table::new()
480            .with_column(Column::new("A"))
481            .with_column(Column::new("B"));
482        table.add_row(Row::new(vec![Cell::new("1"), Cell::new("2")]));
483        let panel = Panel::from_text("Panel body");
484
485        console.rule(Some("Section"));
486        console.rule(None);
487        console.print_styled("Styled", Style::new().bold());
488        console.print_table(&table, "table fallback");
489        console.print_panel(&panel, "panel fallback");
490        console.render(&Rule::new());
491
492        let mut called = false;
493        console.render_or(
494            |c| {
495                called = true;
496                c.print("render_or rich");
497            },
498            "render_or fallback",
499        );
500        assert!(called);
501
502        let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
503            .unwrap_or_default();
504        assert!(output.contains("Section"));
505        assert!(output.contains("Styled"));
506        assert!(output.contains("Panel body"));
507        assert!(output.contains("render_or rich"));
508    }
509
510    // =========================================================================
511    // Additional coverage tests (bd-m32k)
512    // =========================================================================
513
514    #[test]
515    fn strip_markup_trailing_backslash() {
516        // Backslash at end of string with no following char
517        assert_eq!(strip_markup("path\\"), "path\\");
518    }
519
520    #[test]
521    fn strip_markup_backslash_non_special() {
522        // Backslash followed by a char that is NOT [ ] or \
523        assert_eq!(strip_markup("line\\n break"), "line\\n break");
524    }
525
526    #[test]
527    fn strip_markup_backslash_backslash_escape() {
528        // Double backslash → single backslash
529        assert_eq!(strip_markup("a\\\\b"), "a\\b");
530    }
531
532    #[test]
533    fn strip_markup_unclosed_tag() {
534        // Opening bracket with no closing bracket — consumes rest of string
535        assert_eq!(strip_markup("hello [bold no close"), "hello ");
536    }
537
538    #[test]
539    fn with_writer_plain_mode() {
540        let (writer, captured) = SharedWriter::new();
541        let console = FastMcpConsole::with_writer(writer, false);
542
543        assert!(!console.is_rich());
544        console.print_plain("plain text");
545
546        let output = String::from_utf8(captured.lock().unwrap().clone()).unwrap_or_default();
547        assert!(output.contains("plain text"));
548    }
549
550    #[test]
551    fn console_default_impl() {
552        // Default should not panic and should produce a valid console
553        let console = FastMcpConsole::default();
554        // Width/height should return sensible values
555        assert!(console.width() > 0);
556        assert!(console.height() > 0);
557    }
558
559    #[test]
560    fn test_disabled_mode_branches_execute() {
561        let console = FastMcpConsole::with_enabled(false);
562        let table = Table::new().with_column(Column::new("A"));
563        let panel = Panel::from_text("panel");
564
565        console.print("[bold]Hello[/]");
566        console.print_plain("plain");
567        console.render(&Rule::new());
568        console.rule(Some("Title"));
569        console.rule(None);
570        console.newline();
571        console.print_styled("styled", Style::new());
572        console.print_table(&table, "table fallback");
573        console.print_panel(&panel, "panel fallback");
574
575        let mut called = false;
576        console.render_or(
577            |_| {
578                called = true;
579            },
580            "fallback",
581        );
582        assert!(!called);
583    }
584}