fastmcp_console/
console.rs1use crate::theme::FastMcpTheme;
18use rich_rust::prelude::*;
19use rich_rust::renderables::Renderable;
20use std::io::{self, Write};
21use std::sync::{Mutex, OnceLock};
22
23pub struct FastMcpConsole {
38 inner: Mutex<Console>,
39 enabled: bool,
40 theme: &'static FastMcpTheme,
41}
42
43impl FastMcpConsole {
44 #[must_use]
46 pub fn new() -> Self {
47 let enabled = crate::detection::should_enable_rich();
48 Self::with_enabled(enabled)
49 }
50
51 #[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 #[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 pub fn is_rich(&self) -> bool {
108 self.enabled
109 }
110
111 pub fn theme(&self) -> &FastMcpTheme {
113 self.theme
114 }
115
116 pub fn width(&self) -> usize {
118 if let Ok(c) = self.inner.lock() {
119 c.width()
120 } else {
121 80
122 }
123 }
124
125 pub fn height(&self) -> usize {
127 if let Ok(c) = self.inner.lock() {
128 c.height()
129 } else {
130 24
131 }
132 }
133
134 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 pub fn print_plain(&self, text: &str) {
151 if let Ok(console) = self.inner.lock() {
152 let escaped = text.replace('[', "\\[").replace(']', "\\]");
156 console.print(&escaped);
157 } else {
158 eprintln!("{text}");
159 }
160 }
161
162 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 eprintln!("[Complex Output]");
171 }
172 }
173
174 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 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 pub fn newline(&self) {
214 eprintln!();
215 }
216
217 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 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 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
257static CONSOLE: OnceLock<FastMcpConsole> = OnceLock::new();
262
263#[must_use]
272pub fn console() -> &'static FastMcpConsole {
273 CONSOLE.get_or_init(FastMcpConsole::new)
274}
275
276pub 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#[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 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 if let Some('[') = chars.peek() {
323 out.push('[');
324 chars.next(); } else {
326 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 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 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 #[test]
515 fn strip_markup_trailing_backslash() {
516 assert_eq!(strip_markup("path\\"), "path\\");
518 }
519
520 #[test]
521 fn strip_markup_backslash_non_special() {
522 assert_eq!(strip_markup("line\\n break"), "line\\n break");
524 }
525
526 #[test]
527 fn strip_markup_backslash_backslash_escape() {
528 assert_eq!(strip_markup("a\\\\b"), "a\\b");
530 }
531
532 #[test]
533 fn strip_markup_unclosed_tag() {
534 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 let console = FastMcpConsole::default();
554 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}