1use crate::detection::DisplayContext;
7use std::env;
8
9#[derive(Debug, Clone)]
11pub struct ConsoleConfig {
12 pub context: Option<DisplayContext>,
15 pub force_color: Option<bool>,
20 pub force_plain: bool,
22
23 pub show_banner: bool,
26 pub show_capabilities: bool,
31 pub banner_style: BannerStyle,
33
34 pub log_level: log::LevelFilter,
37 pub log_timestamps: bool,
39 pub log_targets: bool,
41 pub log_file_line: bool,
43
44 pub traffic_verbosity: TrafficVerbosity,
48
49 pub show_suggestions: bool,
52 pub show_error_codes: bool,
54 pub show_backtrace: bool,
61
62 pub max_table_rows: usize,
65 pub max_json_depth: usize,
67 pub truncate_at: usize,
69}
70
71#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
73pub enum BannerStyle {
74 #[default]
76 Full,
77 Compact,
79 Minimal,
81 None,
83}
84
85#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
87pub enum TrafficVerbosity {
88 #[default]
90 None,
91 Summary,
93 Full,
95}
96
97impl Default for ConsoleConfig {
98 fn default() -> Self {
99 Self {
100 context: None,
101 force_color: None,
102 force_plain: false,
103 show_banner: true,
104 show_capabilities: true,
105 banner_style: BannerStyle::Full,
106 log_level: log::LevelFilter::Info,
107 log_timestamps: true,
108 log_targets: true,
109 log_file_line: false,
110 traffic_verbosity: TrafficVerbosity::None,
111 show_suggestions: true,
112 show_error_codes: true,
113 show_backtrace: false,
114 max_table_rows: 100,
115 max_json_depth: 5,
116 truncate_at: 200,
117 }
118 }
119}
120
121impl ConsoleConfig {
122 #[must_use]
124 pub fn new() -> Self {
125 Self::default()
126 }
127
128 #[must_use]
146 pub fn from_env() -> Self {
147 Self::from_lookup(|key| env::var(key).ok())
148 }
149
150 fn from_lookup<F>(lookup: F) -> Self
151 where
152 F: Fn(&str) -> Option<String>,
153 {
154 let mut config = Self::default();
155
156 if lookup("FASTMCP_FORCE_COLOR").is_some() {
158 config.force_color = Some(true);
159 }
160 if lookup("FASTMCP_PLAIN").is_some() || lookup("NO_COLOR").is_some() {
161 config.force_plain = true;
162 }
163
164 if let Some(val) = lookup("FASTMCP_BANNER") {
166 config.banner_style = match val.to_lowercase().as_str() {
167 "compact" => BannerStyle::Compact,
168 "minimal" => BannerStyle::Minimal,
169 "none" | "0" | "false" => BannerStyle::None,
170 _ => BannerStyle::Full,
172 };
173 config.show_banner = !matches!(config.banner_style, BannerStyle::None);
174 }
175 if lookup("FASTMCP_NO_BANNER").is_some_and(|value| {
176 matches!(
177 value.to_ascii_lowercase().as_str(),
178 "1" | "true" | "yes" | "on"
179 )
180 }) {
181 config.show_banner = false;
182 config.banner_style = BannerStyle::None;
183 }
184
185 if let Some(level) = lookup("FASTMCP_LOG") {
187 config.log_level = match level.to_lowercase().as_str() {
188 "off" => log::LevelFilter::Off,
189 "trace" => log::LevelFilter::Trace,
190 "debug" => log::LevelFilter::Debug,
191 "info" => log::LevelFilter::Info,
192 "warn" | "warning" => log::LevelFilter::Warn,
193 "error" => log::LevelFilter::Error,
194 _ => config.log_level,
195 };
196 }
197 if lookup("FASTMCP_LOG_TIMESTAMPS")
198 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
199 .unwrap_or(false)
200 {
201 config.log_timestamps = false;
202 }
203 if lookup("FASTMCP_LOG_TARGETS")
204 .map(|v| matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
205 .unwrap_or(false)
206 {
207 config.log_targets = false;
208 }
209 config.log_file_line = lookup("FASTMCP_LOG_FILE_LINE")
210 .is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
211
212 if let Some(val) = lookup("FASTMCP_TRAFFIC") {
214 config.traffic_verbosity = match val.to_lowercase().as_str() {
215 "summary" | "1" => TrafficVerbosity::Summary,
216 "full" | "2" => TrafficVerbosity::Full,
217 _ => TrafficVerbosity::None,
219 };
220 }
221
222 config.show_backtrace = lookup("RUST_BACKTRACE")
224 .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "full"));
225
226 config
227 }
228
229 #[must_use]
239 pub fn force_color(mut self, force: bool) -> Self {
240 self.force_color = Some(force);
241 self
242 }
243
244 #[must_use]
246 pub fn plain_mode(mut self) -> Self {
247 self.force_plain = true;
248 self
249 }
250
251 #[must_use]
253 pub fn with_banner(mut self, style: BannerStyle) -> Self {
254 self.banner_style = style;
255 self.show_banner = !matches!(style, BannerStyle::None);
256 self
257 }
258
259 #[must_use]
261 pub fn without_banner(mut self) -> Self {
262 self.show_banner = false;
263 self.banner_style = BannerStyle::None;
264 self
265 }
266
267 #[must_use]
269 pub fn with_log_level(mut self, level: log::Level) -> Self {
270 self.log_level = level.to_level_filter();
271 self
272 }
273
274 #[must_use]
276 pub fn with_log_level_filter(mut self, level: log::LevelFilter) -> Self {
277 self.log_level = level;
278 self
279 }
280
281 #[must_use]
283 pub fn with_traffic(mut self, verbosity: TrafficVerbosity) -> Self {
284 self.traffic_verbosity = verbosity;
285 self
286 }
287
288 #[must_use]
290 pub fn without_suggestions(mut self) -> Self {
291 self.show_suggestions = false;
292 self
293 }
294
295 #[must_use]
297 pub fn with_context(mut self, context: DisplayContext) -> Self {
298 self.context = Some(context);
299 self
300 }
301
302 #[must_use]
304 pub fn with_max_table_rows(mut self, max: usize) -> Self {
305 self.max_table_rows = max;
306 self
307 }
308
309 #[must_use]
311 pub fn with_max_json_depth(mut self, max: usize) -> Self {
312 self.max_json_depth = max;
313 self
314 }
315
316 #[must_use]
318 pub fn with_truncate_at(mut self, len: usize) -> Self {
319 self.truncate_at = len;
320 self
321 }
322
323 #[must_use]
329 pub fn theme(&self) -> &'static crate::theme::FastMcpTheme {
330 crate::theme::theme()
331 }
332
333 #[must_use]
339 pub fn resolve_context(&self) -> DisplayContext {
340 if self.force_plain {
341 return DisplayContext::new_agent();
342 }
343 if let Some(force_color) = self.force_color {
344 return if force_color {
345 DisplayContext::new_human()
346 } else {
347 DisplayContext::new_agent()
348 };
349 }
350 self.context.unwrap_or_else(DisplayContext::detect)
351 }
352
353 #[must_use]
355 pub fn should_use_rich(&self) -> bool {
356 self.resolve_context().is_human()
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use std::collections::HashMap;
364
365 fn config_from_pairs(pairs: &[(&str, &str)]) -> ConsoleConfig {
366 let map: HashMap<&str, &str> = pairs.iter().copied().collect();
367 ConsoleConfig::from_lookup(|key| map.get(key).map(|v| (*v).to_string()))
368 }
369
370 #[test]
371 fn test_default_config() {
372 let config = ConsoleConfig::new();
373 assert!(config.show_banner);
374 assert!(config.show_capabilities);
375 assert_eq!(config.banner_style, BannerStyle::Full);
376 assert!(config.log_timestamps);
377 assert!(!config.force_plain);
378 assert_eq!(config.max_table_rows, 100);
379 }
380
381 #[test]
382 fn test_builder_pattern() {
383 let config = ConsoleConfig::new()
384 .with_banner(BannerStyle::Compact)
385 .with_log_level(log::Level::Debug)
386 .with_traffic(TrafficVerbosity::Summary);
387
388 assert_eq!(config.banner_style, BannerStyle::Compact);
389 assert_eq!(config.log_level, log::LevelFilter::Debug);
390 assert_eq!(config.traffic_verbosity, TrafficVerbosity::Summary);
391 }
392
393 #[test]
394 fn test_plain_mode() {
395 let config = ConsoleConfig::new().plain_mode();
396 assert!(config.force_plain);
397 assert_eq!(config.resolve_context(), DisplayContext::Agent);
398 }
399
400 #[test]
401 fn test_force_color() {
402 let config = ConsoleConfig::new().force_color(true);
403 assert_eq!(config.force_color, Some(true));
404 assert_eq!(config.resolve_context(), DisplayContext::Human);
405 }
406
407 #[test]
408 fn test_without_banner() {
409 let config = ConsoleConfig::new().without_banner();
410 assert!(!config.show_banner);
411 assert_eq!(config.banner_style, BannerStyle::None);
412 }
413
414 #[test]
415 fn test_from_lookup_defaults_when_empty() {
416 let config = config_from_pairs(&[]);
417 assert_eq!(config.banner_style, BannerStyle::Full);
418 assert_eq!(config.log_level, log::LevelFilter::Info);
419 assert!(config.log_timestamps);
420 assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
421 assert!(!config.show_backtrace);
422 }
423
424 #[test]
425 fn test_from_lookup_display_mode_flags() {
426 let config = config_from_pairs(&[("FASTMCP_FORCE_COLOR", "1"), ("FASTMCP_PLAIN", "1")]);
427 assert_eq!(config.force_color, Some(true));
428 assert!(config.force_plain);
429 assert_eq!(config.resolve_context(), DisplayContext::Agent);
430 assert!(!config.should_use_rich());
431
432 let no_color = config_from_pairs(&[("NO_COLOR", "1")]);
433 assert!(no_color.force_plain);
434 }
435
436 #[test]
437 fn test_from_lookup_banner_variants() {
438 let compact = config_from_pairs(&[("FASTMCP_BANNER", "compact")]);
439 assert_eq!(compact.banner_style, BannerStyle::Compact);
440 assert!(compact.show_banner);
441
442 let minimal = config_from_pairs(&[("FASTMCP_BANNER", "minimal")]);
443 assert_eq!(minimal.banner_style, BannerStyle::Minimal);
444 assert!(minimal.show_banner);
445
446 let none_false = config_from_pairs(&[("FASTMCP_BANNER", "false")]);
447 assert_eq!(none_false.banner_style, BannerStyle::None);
448 assert!(!none_false.show_banner);
449
450 let none_zero = config_from_pairs(&[("FASTMCP_BANNER", "0")]);
451 assert_eq!(none_zero.banner_style, BannerStyle::None);
452 assert!(!none_zero.show_banner);
453
454 let fallback = config_from_pairs(&[("FASTMCP_BANNER", "unknown")]);
455 assert_eq!(fallback.banner_style, BannerStyle::Full);
456 assert!(fallback.show_banner);
457
458 for truthy in ["1", "true", "yes", "on"] {
459 let disabled =
460 config_from_pairs(&[("FASTMCP_BANNER", "full"), ("FASTMCP_NO_BANNER", truthy)]);
461 assert_eq!(disabled.banner_style, BannerStyle::None);
462 assert!(!disabled.show_banner);
463 }
464
465 for falsey in ["0", "false", "no", "off", ""] {
466 let enabled = config_from_pairs(&[("FASTMCP_NO_BANNER", falsey)]);
467 assert_eq!(enabled.banner_style, BannerStyle::Full);
468 assert!(enabled.show_banner);
469 }
470 }
471
472 #[test]
473 fn test_from_lookup_log_levels_and_timestamp_toggle() {
474 let trace = config_from_pairs(&[("FASTMCP_LOG", "trace")]);
475 assert_eq!(trace.log_level, log::LevelFilter::Trace);
476
477 let debug = config_from_pairs(&[("FASTMCP_LOG", "debug")]);
478 assert_eq!(debug.log_level, log::LevelFilter::Debug);
479
480 let warn_alias = config_from_pairs(&[("FASTMCP_LOG", "warning")]);
481 assert_eq!(warn_alias.log_level, log::LevelFilter::Warn);
482
483 let off = config_from_pairs(&[("FASTMCP_LOG", "off")]);
484 assert_eq!(off.log_level, log::LevelFilter::Off);
485
486 let invalid = config_from_pairs(&[("FASTMCP_LOG", "verbose")]);
487 assert_eq!(invalid.log_level, log::LevelFilter::Info);
488
489 let timestamps_disabled_zero = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "0")]);
490 assert!(!timestamps_disabled_zero.log_timestamps);
491
492 let timestamps_disabled_false = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "false")]);
493 assert!(!timestamps_disabled_false.log_timestamps);
494
495 let timestamps_disabled_no = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "no")]);
496 assert!(!timestamps_disabled_no.log_timestamps);
497
498 let timestamps_enabled = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "1")]);
499 assert!(timestamps_enabled.log_timestamps);
500
501 let targets_disabled = config_from_pairs(&[("FASTMCP_LOG_TARGETS", "no")]);
502 assert!(!targets_disabled.log_targets);
503
504 let file_line_enabled = config_from_pairs(&[("FASTMCP_LOG_FILE_LINE", "yes")]);
505 assert!(file_line_enabled.log_file_line);
506 }
507
508 #[test]
509 fn test_from_lookup_traffic_variants_and_backtrace() {
510 let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "summary")]);
511 assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
512
513 let full = config_from_pairs(&[("FASTMCP_TRAFFIC", "2")]);
514 assert_eq!(full.traffic_verbosity, TrafficVerbosity::Full);
515
516 let none = config_from_pairs(&[("FASTMCP_TRAFFIC", "none")]);
517 assert_eq!(none.traffic_verbosity, TrafficVerbosity::None);
518
519 let unknown = config_from_pairs(&[("FASTMCP_TRAFFIC", "loud")]);
520 assert_eq!(unknown.traffic_verbosity, TrafficVerbosity::None);
521
522 let backtrace = config_from_pairs(&[("RUST_BACKTRACE", "full")]);
523 assert!(backtrace.show_backtrace);
524
525 let disabled_backtrace = config_from_pairs(&[("RUST_BACKTRACE", "0")]);
526 assert!(!disabled_backtrace.show_backtrace);
527 }
528
529 #[test]
530 fn test_additional_builder_methods_and_accessors() {
531 let config = ConsoleConfig::new()
532 .without_suggestions()
533 .with_context(DisplayContext::new_agent())
534 .with_max_table_rows(50)
535 .with_max_json_depth(3)
536 .with_truncate_at(80);
537
538 assert!(!config.show_suggestions);
539 assert_eq!(config.context, Some(DisplayContext::Agent));
540 assert_eq!(config.max_table_rows, 50);
541 assert_eq!(config.max_json_depth, 3);
542 assert_eq!(config.truncate_at, 80);
543 assert!(std::ptr::eq(config.theme(), crate::theme::theme()));
544 }
545
546 #[test]
547 fn test_context_resolution_and_should_use_rich() {
548 let plain = ConsoleConfig::new().plain_mode();
549 assert_eq!(plain.resolve_context(), DisplayContext::Agent);
550 assert!(!plain.should_use_rich());
551
552 let forced_rich = ConsoleConfig::new().force_color(true);
553 assert_eq!(forced_rich.resolve_context(), DisplayContext::Human);
554 assert!(forced_rich.should_use_rich());
555
556 let explicit_agent = ConsoleConfig::new().with_context(DisplayContext::new_agent());
557 assert_eq!(explicit_agent.resolve_context(), DisplayContext::Agent);
558 assert!(!explicit_agent.should_use_rich());
559
560 let explicit_human = ConsoleConfig::new().with_context(DisplayContext::new_human());
561 assert_eq!(explicit_human.resolve_context(), DisplayContext::Human);
562 assert!(explicit_human.should_use_rich());
563 }
564
565 #[test]
566 fn test_builder_methods_via_fn_pointers() {
567 let set_force_color: fn(ConsoleConfig, bool) -> ConsoleConfig = ConsoleConfig::force_color;
568 let set_banner: fn(ConsoleConfig, BannerStyle) -> ConsoleConfig =
569 ConsoleConfig::with_banner;
570 let set_log: fn(ConsoleConfig, log::Level) -> ConsoleConfig = ConsoleConfig::with_log_level;
571 let set_traffic: fn(ConsoleConfig, TrafficVerbosity) -> ConsoleConfig =
572 ConsoleConfig::with_traffic;
573 let disable_suggestions: fn(ConsoleConfig) -> ConsoleConfig =
574 ConsoleConfig::without_suggestions;
575 let set_context: fn(ConsoleConfig, DisplayContext) -> ConsoleConfig =
576 ConsoleConfig::with_context;
577 let set_rows: fn(ConsoleConfig, usize) -> ConsoleConfig =
578 ConsoleConfig::with_max_table_rows;
579 let set_depth: fn(ConsoleConfig, usize) -> ConsoleConfig =
580 ConsoleConfig::with_max_json_depth;
581 let set_truncate: fn(ConsoleConfig, usize) -> ConsoleConfig =
582 ConsoleConfig::with_truncate_at;
583
584 let config = set_truncate(
585 set_depth(
586 set_rows(
587 set_context(
588 disable_suggestions(set_traffic(
589 set_log(
590 set_banner(
591 set_force_color(ConsoleConfig::new(), false),
592 BannerStyle::None,
593 ),
594 log::Level::Error,
595 ),
596 TrafficVerbosity::Full,
597 )),
598 DisplayContext::new_human(),
599 ),
600 12,
601 ),
602 7,
603 ),
604 42,
605 );
606
607 assert_eq!(config.force_color, Some(false));
608 assert_eq!(config.banner_style, BannerStyle::None);
609 assert!(!config.show_banner);
610 assert_eq!(config.log_level, log::LevelFilter::Error);
611 assert_eq!(config.traffic_verbosity, TrafficVerbosity::Full);
612 assert!(!config.show_suggestions);
613 assert_eq!(config.context, Some(DisplayContext::Human));
614 assert_eq!(config.max_table_rows, 12);
615 assert_eq!(config.max_json_depth, 7);
616 assert_eq!(config.truncate_at, 42);
617 }
618
619 #[test]
620 fn test_from_env_and_fallback_context_resolution_paths() {
621 let _ = ConsoleConfig::from_env();
622
623 let forced_false = ConsoleConfig::new()
624 .force_color(false)
625 .with_context(DisplayContext::new_agent());
626 assert_eq!(forced_false.resolve_context(), DisplayContext::Agent);
627 assert!(!forced_false.should_use_rich());
628
629 let explicit_human = ConsoleConfig::new()
630 .force_color(false)
631 .with_context(DisplayContext::new_human());
632 assert_eq!(explicit_human.resolve_context(), DisplayContext::Agent);
633 assert!(!explicit_human.should_use_rich());
634 }
635
636 #[test]
641 fn banner_style_and_traffic_verbosity_defaults() {
642 assert_eq!(BannerStyle::default(), BannerStyle::Full);
643 assert_eq!(TrafficVerbosity::default(), TrafficVerbosity::None);
644 }
645
646 #[test]
647 fn console_config_debug_and_clone() {
648 let config = ConsoleConfig::new()
649 .with_log_level(log::Level::Info)
650 .with_max_table_rows(42);
651 let debug = format!("{config:?}");
652 assert!(debug.contains("ConsoleConfig"));
653 assert!(debug.contains("42"));
654
655 let cloned = config.clone();
656 assert_eq!(cloned.max_table_rows, 42);
657 assert_eq!(cloned.log_level, log::LevelFilter::Info);
658 }
659
660 #[test]
661 fn from_lookup_banner_none_literal_and_full_explicit() {
662 let none = config_from_pairs(&[("FASTMCP_BANNER", "none")]);
663 assert_eq!(none.banner_style, BannerStyle::None);
664 assert!(!none.show_banner);
665
666 let full = config_from_pairs(&[("FASTMCP_BANNER", "full")]);
667 assert_eq!(full.banner_style, BannerStyle::Full);
668 assert!(full.show_banner);
669 }
670
671 #[test]
672 fn from_lookup_remaining_log_levels() {
673 let info = config_from_pairs(&[("FASTMCP_LOG", "info")]);
674 assert_eq!(info.log_level, log::LevelFilter::Info);
675
676 let warn = config_from_pairs(&[("FASTMCP_LOG", "warn")]);
677 assert_eq!(warn.log_level, log::LevelFilter::Warn);
678
679 let error = config_from_pairs(&[("FASTMCP_LOG", "error")]);
680 assert_eq!(error.log_level, log::LevelFilter::Error);
681 }
682
683 #[test]
684 fn from_lookup_traffic_numeric_one() {
685 let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "1")]);
686 assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
687 }
688
689 #[test]
690 fn with_banner_none_clears_show_banner() {
691 let config = ConsoleConfig::new().with_banner(BannerStyle::None);
692 assert!(!config.show_banner);
693 assert_eq!(config.banner_style, BannerStyle::None);
694 }
695
696 #[test]
697 fn with_traffic_none_disables_traffic() {
698 let config = ConsoleConfig::new()
699 .with_traffic(TrafficVerbosity::Full)
700 .with_traffic(TrafficVerbosity::None);
701 assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
702 }
703
704 #[test]
705 fn default_fields_full_coverage() {
706 let config = ConsoleConfig::default();
707 assert!(config.log_targets);
708 assert!(!config.log_file_line);
709 assert!(config.show_error_codes);
710 assert_eq!(config.max_json_depth, 5);
711 assert_eq!(config.truncate_at, 200);
712 assert!(config.show_suggestions);
713 assert!(config.context.is_none());
714 assert!(config.force_color.is_none());
715 }
716}