use crate::theme::FastMcpTheme;
use rich_rust::prelude::*;
use rich_rust::renderables::Renderable;
use std::io::{self, Write};
use std::sync::{Mutex, OnceLock};
pub struct FastMcpConsole {
inner: Mutex<Console>,
enabled: bool,
theme: &'static FastMcpTheme,
}
impl FastMcpConsole {
#[must_use]
pub fn new() -> Self {
let enabled = crate::detection::should_enable_rich();
Self::with_enabled(enabled)
}
#[must_use]
pub fn with_enabled(enabled: bool) -> Self {
let inner = if enabled {
Console::builder()
.file(Box::new(io::stderr()))
.force_terminal(true)
.markup(true)
.emoji(true)
.build()
} else {
Console::builder()
.file(Box::new(io::stderr()))
.no_color()
.markup(false)
.emoji(false)
.build()
};
Self {
inner: Mutex::new(inner),
enabled,
theme: crate::theme::theme(),
}
}
#[must_use]
pub fn with_writer<W: Write + Send + 'static>(writer: W, enabled: bool) -> Self {
let mut builder = Console::builder()
.file(Box::new(writer))
.markup(enabled)
.emoji(enabled);
if !enabled {
builder = builder.no_color();
}
let inner = if enabled {
builder.force_terminal(true).build()
} else {
builder.build()
};
Self {
inner: Mutex::new(inner),
enabled,
theme: crate::theme::theme(),
}
}
pub fn is_rich(&self) -> bool {
self.enabled
}
pub fn theme(&self) -> &FastMcpTheme {
self.theme
}
pub fn width(&self) -> usize {
if let Ok(c) = self.inner.lock() {
c.width()
} else {
80
}
}
pub fn height(&self) -> usize {
if let Ok(c) = self.inner.lock() {
c.height()
} else {
24
}
}
pub fn print(&self, content: &str) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
console.print(content);
}
} else {
eprintln!("{}", strip_markup(content));
}
}
pub fn print_plain(&self, text: &str) {
if let Ok(console) = self.inner.lock() {
let escaped = text.replace('[', "\\[").replace(']', "\\]");
console.print(&escaped);
} else {
eprintln!("{text}");
}
}
pub fn render<R: Renderable>(&self, renderable: &R) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
console.print_renderable(renderable);
}
} else {
eprintln!("[Complex Output]");
}
}
pub fn render_or<F>(&self, render_op: F, plain_fallback: &str)
where
F: FnOnce(&Console),
{
if self.enabled {
if let Ok(console) = self.inner.lock() {
render_op(&console);
}
} else {
eprintln!("{plain_fallback}");
}
}
pub fn rule(&self, title: Option<&str>) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
match title {
Some(t) => console.print_renderable(
&Rule::with_title(t).style(self.theme.border_style.clone()),
),
None => console
.print_renderable(&Rule::new().style(self.theme.border_style.clone())),
}
}
} else {
match title {
Some(t) => eprintln!("--- {t} ---"),
None => eprintln!("---"),
}
}
}
pub fn newline(&self) {
eprintln!();
}
pub fn print_styled(&self, text: &str, style: Style) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
console.print_styled(text, style);
}
} else {
eprintln!("{text}");
}
}
pub fn print_table(&self, table: &Table, plain_fallback: &str) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
console.print_renderable(table);
}
} else {
eprintln!("{plain_fallback}");
}
}
pub fn print_panel(&self, panel: &Panel, plain_fallback: &str) {
if self.enabled {
if let Ok(console) = self.inner.lock() {
console.print_renderable(panel);
}
} else {
eprintln!("{plain_fallback}");
}
}
}
impl Default for FastMcpConsole {
fn default() -> Self {
Self::new()
}
}
static CONSOLE: OnceLock<FastMcpConsole> = OnceLock::new();
#[must_use]
pub fn console() -> &'static FastMcpConsole {
CONSOLE.get_or_init(FastMcpConsole::new)
}
pub fn init_console(enabled: bool) -> Result<(), &'static str> {
CONSOLE
.set(FastMcpConsole::with_enabled(enabled))
.map_err(|_| "Console already initialized")
}
#[must_use]
pub fn strip_markup(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(next) = chars.peek().copied() {
if next == '[' || next == ']' || next == '\\' {
out.push(next);
chars.next();
} else {
out.push('\\');
}
} else {
out.push('\\');
}
}
'[' => {
if let Some('[') = chars.peek() {
out.push('[');
chars.next(); } else {
for c in chars.by_ref() {
if c == ']' {
break;
}
}
}
}
_ => out.push(ch),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
struct SharedWriter {
buf: Arc<Mutex<Vec<u8>>>,
}
impl SharedWriter {
fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
let buf = Arc::new(Mutex::new(Vec::new()));
(
Self {
buf: Arc::clone(&buf),
},
buf,
)
}
}
impl Write for SharedWriter {
fn write(&mut self, input: &[u8]) -> std::io::Result<usize> {
if let Ok(mut guard) = self.buf.lock() {
guard.extend_from_slice(input);
}
Ok(input.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[test]
fn test_strip_markup_simple() {
assert_eq!(strip_markup("[bold]Hello[/]"), "Hello");
}
#[test]
fn test_strip_markup_nested() {
assert_eq!(strip_markup("[bold][red]Error[/][/]"), "Error");
}
#[test]
fn test_strip_markup_multiple_tags() {
assert_eq!(
strip_markup("[green]✓[/] Success [dim](100ms)[/]"),
"✓ Success (100ms)"
);
}
#[test]
fn test_strip_markup_no_tags() {
assert_eq!(strip_markup("Plain text"), "Plain text");
}
#[test]
fn test_strip_markup_empty() {
assert_eq!(strip_markup(""), "");
}
#[test]
fn test_strip_markup_only_tags() {
assert_eq!(strip_markup("[bold][/]"), "");
}
#[test]
fn test_strip_markup_preserves_unicode() {
assert_eq!(strip_markup("[info]⚡ Fast[/]"), "⚡ Fast");
}
#[test]
fn test_strip_markup_preserves_backslash_escaped_brackets() {
assert_eq!(
strip_markup(r"tools/list \[OK\] 12ms"),
"tools/list [OK] 12ms"
);
assert_eq!(strip_markup(r"\[x\]"), "[x]");
assert_eq!(strip_markup(r"\\[bold]x[/]"), r"\x");
}
#[test]
fn test_strip_markup_double_bracket_escape() {
assert_eq!(strip_markup("[[literal]]"), "[literal]]");
}
#[test]
fn test_console_with_enabled_true() {
let console = FastMcpConsole::with_enabled(true);
assert!(console.is_rich());
}
#[test]
fn test_console_with_enabled_false() {
let console = FastMcpConsole::with_enabled(false);
assert!(!console.is_rich());
}
#[test]
fn test_console_theme_access() {
let console = FastMcpConsole::with_enabled(false);
let theme = console.theme();
assert_eq!(theme.primary.triplet.map(|tr| tr.blue), Some(255));
}
#[test]
fn test_console_dimensions_default() {
let console = FastMcpConsole::with_enabled(false);
assert!(console.width() > 0);
assert!(console.height() > 0);
}
#[test]
fn test_with_writer_print_and_print_plain_paths() {
let (writer, captured) = SharedWriter::new();
let console = FastMcpConsole::with_writer(writer, true);
console.print("[bold]Hello[/]");
console.print_plain("[literal]");
let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
.unwrap_or_default();
assert!(output.contains("Hello"));
assert!(output.contains("literal"));
}
#[test]
fn test_render_and_convenience_methods_in_rich_mode() {
let (writer, captured) = SharedWriter::new();
let console = FastMcpConsole::with_writer(writer, true);
let mut table = Table::new()
.with_column(Column::new("A"))
.with_column(Column::new("B"));
table.add_row(Row::new(vec![Cell::new("1"), Cell::new("2")]));
let panel = Panel::from_text("Panel body");
console.rule(Some("Section"));
console.rule(None);
console.print_styled("Styled", Style::new().bold());
console.print_table(&table, "table fallback");
console.print_panel(&panel, "panel fallback");
console.render(&Rule::new());
let mut called = false;
console.render_or(
|c| {
called = true;
c.print("render_or rich");
},
"render_or fallback",
);
assert!(called);
let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
.unwrap_or_default();
assert!(output.contains("Section"));
assert!(output.contains("Styled"));
assert!(output.contains("Panel body"));
assert!(output.contains("render_or rich"));
}
#[test]
fn strip_markup_trailing_backslash() {
assert_eq!(strip_markup("path\\"), "path\\");
}
#[test]
fn strip_markup_backslash_non_special() {
assert_eq!(strip_markup("line\\n break"), "line\\n break");
}
#[test]
fn strip_markup_backslash_backslash_escape() {
assert_eq!(strip_markup("a\\\\b"), "a\\b");
}
#[test]
fn strip_markup_unclosed_tag() {
assert_eq!(strip_markup("hello [bold no close"), "hello ");
}
#[test]
fn with_writer_plain_mode() {
let (writer, captured) = SharedWriter::new();
let console = FastMcpConsole::with_writer(writer, false);
assert!(!console.is_rich());
console.print_plain("plain text");
let output = String::from_utf8(captured.lock().unwrap().clone()).unwrap_or_default();
assert!(output.contains("plain text"));
}
#[test]
fn console_default_impl() {
let console = FastMcpConsole::default();
assert!(console.width() > 0);
assert!(console.height() > 0);
}
#[test]
fn test_disabled_mode_branches_execute() {
let console = FastMcpConsole::with_enabled(false);
let table = Table::new().with_column(Column::new("A"));
let panel = Panel::from_text("panel");
console.print("[bold]Hello[/]");
console.print_plain("plain");
console.render(&Rule::new());
console.rule(Some("Title"));
console.rule(None);
console.newline();
console.print_styled("styled", Style::new());
console.print_table(&table, "table fallback");
console.print_panel(&panel, "panel fallback");
let mut called = false;
console.render_or(
|_| {
called = true;
},
"fallback",
);
assert!(!called);
}
}