1use crate::detection::DisplayContext;
8use std::env;
9
10#[derive(Debug, Clone)]
12pub struct ConsoleConfig {
13 pub context: Option<DisplayContext>,
16 pub force_color: Option<bool>,
18 pub force_plain: bool,
20
21 pub custom_colors: Option<CustomColors>,
24
25 pub show_banner: bool,
28 pub show_capabilities: bool,
30 pub banner_style: BannerStyle,
32
33 pub log_level: Option<log::Level>,
36 pub log_timestamps: bool,
38 pub log_targets: bool,
40 pub log_file_line: bool,
42
43 pub show_stats_periodic: bool,
46 pub stats_interval_secs: u64,
48 pub show_request_traffic: bool,
50 pub traffic_verbosity: TrafficVerbosity,
52
53 pub show_suggestions: bool,
56 pub show_error_codes: bool,
58 pub show_backtrace: bool,
60
61 pub max_table_rows: usize,
64 pub max_json_depth: usize,
66 pub truncate_at: usize,
68}
69
70#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
72pub enum BannerStyle {
73 #[default]
75 Full,
76 Compact,
78 Minimal,
80 None,
82}
83
84#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
86pub enum TrafficVerbosity {
87 #[default]
89 None,
90 Summary,
92 Headers,
94 Full,
96}
97
98#[derive(Debug, Clone, Default)]
100pub struct CustomColors {
101 pub primary: Option<String>,
103 pub secondary: Option<String>,
105 pub success: Option<String>,
107 pub warning: Option<String>,
109 pub error: Option<String>,
111}
112
113impl Default for ConsoleConfig {
114 fn default() -> Self {
115 Self {
116 context: None,
117 force_color: None,
118 force_plain: false,
119 custom_colors: None,
120 show_banner: true,
121 show_capabilities: true,
122 banner_style: BannerStyle::Full,
123 log_level: None,
124 log_timestamps: true,
125 log_targets: true,
126 log_file_line: false,
127 show_stats_periodic: false,
128 stats_interval_secs: 60,
129 show_request_traffic: false,
130 traffic_verbosity: TrafficVerbosity::None,
131 show_suggestions: true,
132 show_error_codes: true,
133 show_backtrace: false,
134 max_table_rows: 100,
135 max_json_depth: 5,
136 truncate_at: 200,
137 }
138 }
139}
140
141impl ConsoleConfig {
142 #[must_use]
144 pub fn new() -> Self {
145 Self::default()
146 }
147
148 #[must_use]
163 pub fn from_env() -> Self {
164 Self::from_lookup(|key| env::var(key).ok())
165 }
166
167 fn from_lookup<F>(lookup: F) -> Self
168 where
169 F: Fn(&str) -> Option<String>,
170 {
171 let mut config = Self::default();
172
173 if lookup("FASTMCP_FORCE_COLOR").is_some() {
175 config.force_color = Some(true);
176 }
177 if lookup("FASTMCP_PLAIN").is_some() || lookup("NO_COLOR").is_some() {
178 config.force_plain = true;
179 }
180
181 if let Some(val) = lookup("FASTMCP_BANNER") {
183 config.banner_style = match val.to_lowercase().as_str() {
184 "compact" => BannerStyle::Compact,
185 "minimal" => BannerStyle::Minimal,
186 "none" | "0" | "false" => BannerStyle::None,
187 _ => BannerStyle::Full,
189 };
190 config.show_banner = !matches!(config.banner_style, BannerStyle::None);
191 }
192
193 if let Some(level) = lookup("FASTMCP_LOG") {
195 config.log_level = match level.to_lowercase().as_str() {
196 "trace" => Some(log::Level::Trace),
197 "debug" => Some(log::Level::Debug),
198 "info" => Some(log::Level::Info),
199 "warn" | "warning" => Some(log::Level::Warn),
200 "error" => Some(log::Level::Error),
201 _ => None,
202 };
203 }
204 if lookup("FASTMCP_LOG_TIMESTAMPS")
205 .map(|v| v == "0" || v.to_lowercase() == "false")
206 .unwrap_or(false)
207 {
208 config.log_timestamps = false;
209 }
210
211 if let Some(val) = lookup("FASTMCP_TRAFFIC") {
213 config.traffic_verbosity = match val.to_lowercase().as_str() {
214 "summary" | "1" => TrafficVerbosity::Summary,
215 "headers" | "2" => TrafficVerbosity::Headers,
216 "full" | "3" => TrafficVerbosity::Full,
217 _ => TrafficVerbosity::None,
219 };
220 config.show_request_traffic =
221 !matches!(config.traffic_verbosity, TrafficVerbosity::None);
222 }
223
224 if lookup("RUST_BACKTRACE").is_some() {
226 config.show_backtrace = true;
227 }
228
229 config
230 }
231
232 #[must_use]
238 pub fn force_color(mut self, force: bool) -> Self {
239 self.force_color = Some(force);
240 self
241 }
242
243 #[must_use]
245 pub fn plain_mode(mut self) -> Self {
246 self.force_plain = true;
247 self
248 }
249
250 #[must_use]
252 pub fn with_banner(mut self, style: BannerStyle) -> Self {
253 self.banner_style = style;
254 self.show_banner = !matches!(style, BannerStyle::None);
255 self
256 }
257
258 #[must_use]
260 pub fn without_banner(mut self) -> Self {
261 self.show_banner = false;
262 self.banner_style = BannerStyle::None;
263 self
264 }
265
266 #[must_use]
268 pub fn with_log_level(mut self, level: log::Level) -> Self {
269 self.log_level = Some(level);
270 self
271 }
272
273 #[must_use]
275 pub fn with_traffic(mut self, verbosity: TrafficVerbosity) -> Self {
276 self.traffic_verbosity = verbosity;
277 self.show_request_traffic = !matches!(verbosity, TrafficVerbosity::None);
278 self
279 }
280
281 #[must_use]
283 pub fn with_periodic_stats(mut self, interval_secs: u64) -> Self {
284 self.show_stats_periodic = true;
285 self.stats_interval_secs = interval_secs;
286 self
287 }
288
289 #[must_use]
291 pub fn without_suggestions(mut self) -> Self {
292 self.show_suggestions = false;
293 self
294 }
295
296 #[must_use]
298 pub fn with_custom_colors(mut self, colors: CustomColors) -> Self {
299 self.custom_colors = Some(colors);
300 self
301 }
302
303 #[must_use]
305 pub fn with_context(mut self, context: DisplayContext) -> Self {
306 self.context = Some(context);
307 self
308 }
309
310 #[must_use]
312 pub fn with_max_table_rows(mut self, max: usize) -> Self {
313 self.max_table_rows = max;
314 self
315 }
316
317 #[must_use]
319 pub fn with_max_json_depth(mut self, max: usize) -> Self {
320 self.max_json_depth = max;
321 self
322 }
323
324 #[must_use]
326 pub fn with_truncate_at(mut self, len: usize) -> Self {
327 self.truncate_at = len;
328 self
329 }
330
331 #[must_use]
337 pub fn theme(&self) -> &'static crate::theme::FastMcpTheme {
338 crate::theme::theme()
339 }
340
341 #[must_use]
347 pub fn resolve_context(&self) -> DisplayContext {
348 if self.force_plain {
349 return DisplayContext::new_agent();
350 }
351 if let Some(true) = self.force_color {
352 return DisplayContext::new_human();
353 }
354 self.context.unwrap_or_else(DisplayContext::detect)
355 }
356
357 #[must_use]
359 pub fn should_use_rich(&self) -> bool {
360 self.resolve_context().is_human()
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use std::collections::HashMap;
368
369 fn config_from_pairs(pairs: &[(&str, &str)]) -> ConsoleConfig {
370 let map: HashMap<&str, &str> = pairs.iter().copied().collect();
371 ConsoleConfig::from_lookup(|key| map.get(key).map(|v| (*v).to_string()))
372 }
373
374 #[test]
375 fn test_default_config() {
376 let config = ConsoleConfig::new();
377 assert!(config.show_banner);
378 assert!(config.show_capabilities);
379 assert_eq!(config.banner_style, BannerStyle::Full);
380 assert!(config.log_timestamps);
381 assert!(!config.force_plain);
382 assert_eq!(config.max_table_rows, 100);
383 }
384
385 #[test]
386 fn test_builder_pattern() {
387 let config = ConsoleConfig::new()
388 .with_banner(BannerStyle::Compact)
389 .with_log_level(log::Level::Debug)
390 .with_traffic(TrafficVerbosity::Summary)
391 .with_periodic_stats(30);
392
393 assert_eq!(config.banner_style, BannerStyle::Compact);
394 assert_eq!(config.log_level, Some(log::Level::Debug));
395 assert_eq!(config.traffic_verbosity, TrafficVerbosity::Summary);
396 assert!(config.show_stats_periodic);
397 assert_eq!(config.stats_interval_secs, 30);
398 }
399
400 #[test]
401 fn test_plain_mode() {
402 let config = ConsoleConfig::new().plain_mode();
403 assert!(config.force_plain);
404 assert_eq!(config.resolve_context(), DisplayContext::Agent);
405 }
406
407 #[test]
408 fn test_force_color() {
409 let config = ConsoleConfig::new().force_color(true);
410 assert_eq!(config.force_color, Some(true));
411 assert_eq!(config.resolve_context(), DisplayContext::Human);
412 }
413
414 #[test]
415 fn test_without_banner() {
416 let config = ConsoleConfig::new().without_banner();
417 assert!(!config.show_banner);
418 assert_eq!(config.banner_style, BannerStyle::None);
419 }
420
421 #[test]
422 fn test_from_lookup_defaults_when_empty() {
423 let config = config_from_pairs(&[]);
424 assert_eq!(config.banner_style, BannerStyle::Full);
425 assert_eq!(config.log_level, None);
426 assert!(config.log_timestamps);
427 assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
428 assert!(!config.show_request_traffic);
429 assert!(!config.show_backtrace);
430 }
431
432 #[test]
433 fn test_from_lookup_display_mode_flags() {
434 let config = config_from_pairs(&[("FASTMCP_FORCE_COLOR", "1"), ("FASTMCP_PLAIN", "1")]);
435 assert_eq!(config.force_color, Some(true));
436 assert!(config.force_plain);
437
438 let no_color = config_from_pairs(&[("NO_COLOR", "1")]);
439 assert!(no_color.force_plain);
440 }
441
442 #[test]
443 fn test_from_lookup_banner_variants() {
444 let compact = config_from_pairs(&[("FASTMCP_BANNER", "compact")]);
445 assert_eq!(compact.banner_style, BannerStyle::Compact);
446 assert!(compact.show_banner);
447
448 let minimal = config_from_pairs(&[("FASTMCP_BANNER", "minimal")]);
449 assert_eq!(minimal.banner_style, BannerStyle::Minimal);
450 assert!(minimal.show_banner);
451
452 let none_false = config_from_pairs(&[("FASTMCP_BANNER", "false")]);
453 assert_eq!(none_false.banner_style, BannerStyle::None);
454 assert!(!none_false.show_banner);
455
456 let none_zero = config_from_pairs(&[("FASTMCP_BANNER", "0")]);
457 assert_eq!(none_zero.banner_style, BannerStyle::None);
458 assert!(!none_zero.show_banner);
459
460 let fallback = config_from_pairs(&[("FASTMCP_BANNER", "unknown")]);
461 assert_eq!(fallback.banner_style, BannerStyle::Full);
462 assert!(fallback.show_banner);
463 }
464
465 #[test]
466 fn test_from_lookup_log_levels_and_timestamp_toggle() {
467 let trace = config_from_pairs(&[("FASTMCP_LOG", "trace")]);
468 assert_eq!(trace.log_level, Some(log::Level::Trace));
469
470 let debug = config_from_pairs(&[("FASTMCP_LOG", "debug")]);
471 assert_eq!(debug.log_level, Some(log::Level::Debug));
472
473 let warn_alias = config_from_pairs(&[("FASTMCP_LOG", "warning")]);
474 assert_eq!(warn_alias.log_level, Some(log::Level::Warn));
475
476 let invalid = config_from_pairs(&[("FASTMCP_LOG", "verbose")]);
477 assert_eq!(invalid.log_level, None);
478
479 let timestamps_disabled_zero = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "0")]);
480 assert!(!timestamps_disabled_zero.log_timestamps);
481
482 let timestamps_disabled_false = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "false")]);
483 assert!(!timestamps_disabled_false.log_timestamps);
484
485 let timestamps_enabled = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "1")]);
486 assert!(timestamps_enabled.log_timestamps);
487 }
488
489 #[test]
490 fn test_from_lookup_traffic_variants_and_backtrace() {
491 let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "summary")]);
492 assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
493 assert!(summary.show_request_traffic);
494
495 let headers = config_from_pairs(&[("FASTMCP_TRAFFIC", "2")]);
496 assert_eq!(headers.traffic_verbosity, TrafficVerbosity::Headers);
497 assert!(headers.show_request_traffic);
498
499 let full = config_from_pairs(&[("FASTMCP_TRAFFIC", "3")]);
500 assert_eq!(full.traffic_verbosity, TrafficVerbosity::Full);
501 assert!(full.show_request_traffic);
502
503 let none = config_from_pairs(&[("FASTMCP_TRAFFIC", "none")]);
504 assert_eq!(none.traffic_verbosity, TrafficVerbosity::None);
505 assert!(!none.show_request_traffic);
506
507 let unknown = config_from_pairs(&[("FASTMCP_TRAFFIC", "loud")]);
508 assert_eq!(unknown.traffic_verbosity, TrafficVerbosity::None);
509 assert!(!unknown.show_request_traffic);
510
511 let backtrace = config_from_pairs(&[("RUST_BACKTRACE", "full")]);
512 assert!(backtrace.show_backtrace);
513 }
514
515 #[test]
516 fn test_additional_builder_methods_and_accessors() {
517 let custom = CustomColors {
518 primary: Some("#123456".to_string()),
519 secondary: None,
520 success: Some("#22aa22".to_string()),
521 warning: None,
522 error: Some("#ff0000".to_string()),
523 };
524
525 let config = ConsoleConfig::new()
526 .without_suggestions()
527 .with_custom_colors(custom.clone())
528 .with_context(DisplayContext::new_agent())
529 .with_max_table_rows(50)
530 .with_max_json_depth(3)
531 .with_truncate_at(80);
532
533 assert!(!config.show_suggestions);
534 assert!(config.custom_colors.is_some());
535 assert_eq!(
536 config
537 .custom_colors
538 .as_ref()
539 .and_then(|c| c.primary.as_deref()),
540 Some("#123456")
541 );
542 assert_eq!(config.context, Some(DisplayContext::Agent));
543 assert_eq!(config.max_table_rows, 50);
544 assert_eq!(config.max_json_depth, 3);
545 assert_eq!(config.truncate_at, 80);
546 assert!(std::ptr::eq(config.theme(), crate::theme::theme()));
547 }
548
549 #[test]
550 fn test_context_resolution_and_should_use_rich() {
551 let plain = ConsoleConfig::new().plain_mode();
552 assert_eq!(plain.resolve_context(), DisplayContext::Agent);
553 assert!(!plain.should_use_rich());
554
555 let forced_rich = ConsoleConfig::new().force_color(true);
556 assert_eq!(forced_rich.resolve_context(), DisplayContext::Human);
557 assert!(forced_rich.should_use_rich());
558
559 let explicit_agent = ConsoleConfig::new().with_context(DisplayContext::new_agent());
560 assert_eq!(explicit_agent.resolve_context(), DisplayContext::Agent);
561 assert!(!explicit_agent.should_use_rich());
562
563 let explicit_human = ConsoleConfig::new().with_context(DisplayContext::new_human());
564 assert_eq!(explicit_human.resolve_context(), DisplayContext::Human);
565 assert!(explicit_human.should_use_rich());
566 }
567
568 #[test]
569 fn test_builder_methods_via_fn_pointers() {
570 let set_force_color: fn(ConsoleConfig, bool) -> ConsoleConfig = ConsoleConfig::force_color;
571 let set_banner: fn(ConsoleConfig, BannerStyle) -> ConsoleConfig =
572 ConsoleConfig::with_banner;
573 let set_log: fn(ConsoleConfig, log::Level) -> ConsoleConfig = ConsoleConfig::with_log_level;
574 let set_traffic: fn(ConsoleConfig, TrafficVerbosity) -> ConsoleConfig =
575 ConsoleConfig::with_traffic;
576 let set_stats: fn(ConsoleConfig, u64) -> ConsoleConfig = ConsoleConfig::with_periodic_stats;
577 let disable_suggestions: fn(ConsoleConfig) -> ConsoleConfig =
578 ConsoleConfig::without_suggestions;
579 let set_custom: fn(ConsoleConfig, CustomColors) -> ConsoleConfig =
580 ConsoleConfig::with_custom_colors;
581 let set_context: fn(ConsoleConfig, DisplayContext) -> ConsoleConfig =
582 ConsoleConfig::with_context;
583 let set_rows: fn(ConsoleConfig, usize) -> ConsoleConfig =
584 ConsoleConfig::with_max_table_rows;
585 let set_depth: fn(ConsoleConfig, usize) -> ConsoleConfig =
586 ConsoleConfig::with_max_json_depth;
587 let set_truncate: fn(ConsoleConfig, usize) -> ConsoleConfig =
588 ConsoleConfig::with_truncate_at;
589
590 let custom = CustomColors {
591 primary: Some("#111111".to_string()),
592 secondary: Some("#222222".to_string()),
593 success: None,
594 warning: Some("#ffaa00".to_string()),
595 error: None,
596 };
597
598 let config = set_truncate(
599 set_depth(
600 set_rows(
601 set_context(
602 set_custom(
603 disable_suggestions(set_stats(
604 set_traffic(
605 set_log(
606 set_banner(
607 set_force_color(ConsoleConfig::new(), false),
608 BannerStyle::None,
609 ),
610 log::Level::Error,
611 ),
612 TrafficVerbosity::Headers,
613 ),
614 15,
615 )),
616 custom.clone(),
617 ),
618 DisplayContext::new_human(),
619 ),
620 12,
621 ),
622 7,
623 ),
624 42,
625 );
626
627 assert_eq!(config.force_color, Some(false));
628 assert_eq!(config.banner_style, BannerStyle::None);
629 assert!(!config.show_banner);
630 assert_eq!(config.log_level, Some(log::Level::Error));
631 assert_eq!(config.traffic_verbosity, TrafficVerbosity::Headers);
632 assert!(config.show_request_traffic);
633 assert!(config.show_stats_periodic);
634 assert_eq!(config.stats_interval_secs, 15);
635 assert!(!config.show_suggestions);
636 assert_eq!(
637 config
638 .custom_colors
639 .as_ref()
640 .and_then(|c| c.secondary.as_deref()),
641 Some("#222222")
642 );
643 assert_eq!(config.context, Some(DisplayContext::Human));
644 assert_eq!(config.max_table_rows, 12);
645 assert_eq!(config.max_json_depth, 7);
646 assert_eq!(config.truncate_at, 42);
647 }
648
649 #[test]
650 fn test_from_env_and_fallback_context_resolution_paths() {
651 let _ = ConsoleConfig::from_env();
652
653 let forced_false = ConsoleConfig::new()
654 .force_color(false)
655 .with_context(DisplayContext::new_agent());
656 assert_eq!(forced_false.resolve_context(), DisplayContext::Agent);
657 assert!(!forced_false.should_use_rich());
658
659 let explicit_human = ConsoleConfig::new()
660 .force_color(false)
661 .with_context(DisplayContext::new_human());
662 assert_eq!(explicit_human.resolve_context(), DisplayContext::Human);
663 assert!(explicit_human.should_use_rich());
664 }
665
666 #[test]
671 fn banner_style_and_traffic_verbosity_defaults() {
672 assert_eq!(BannerStyle::default(), BannerStyle::Full);
673 assert_eq!(TrafficVerbosity::default(), TrafficVerbosity::None);
674 }
675
676 #[test]
677 fn console_config_debug_and_clone() {
678 let config = ConsoleConfig::new()
679 .with_log_level(log::Level::Info)
680 .with_max_table_rows(42);
681 let debug = format!("{config:?}");
682 assert!(debug.contains("ConsoleConfig"));
683 assert!(debug.contains("42"));
684
685 let cloned = config.clone();
686 assert_eq!(cloned.max_table_rows, 42);
687 assert_eq!(cloned.log_level, Some(log::Level::Info));
688 }
689
690 #[test]
691 fn custom_colors_default_all_none() {
692 let colors = CustomColors::default();
693 assert!(colors.primary.is_none());
694 assert!(colors.secondary.is_none());
695 assert!(colors.success.is_none());
696 assert!(colors.warning.is_none());
697 assert!(colors.error.is_none());
698
699 let debug = format!("{colors:?}");
700 assert!(debug.contains("CustomColors"));
701 }
702
703 #[test]
704 fn from_lookup_banner_none_literal_and_full_explicit() {
705 let none = config_from_pairs(&[("FASTMCP_BANNER", "none")]);
706 assert_eq!(none.banner_style, BannerStyle::None);
707 assert!(!none.show_banner);
708
709 let full = config_from_pairs(&[("FASTMCP_BANNER", "full")]);
710 assert_eq!(full.banner_style, BannerStyle::Full);
711 assert!(full.show_banner);
712 }
713
714 #[test]
715 fn from_lookup_remaining_log_levels() {
716 let info = config_from_pairs(&[("FASTMCP_LOG", "info")]);
717 assert_eq!(info.log_level, Some(log::Level::Info));
718
719 let warn = config_from_pairs(&[("FASTMCP_LOG", "warn")]);
720 assert_eq!(warn.log_level, Some(log::Level::Warn));
721
722 let error = config_from_pairs(&[("FASTMCP_LOG", "error")]);
723 assert_eq!(error.log_level, Some(log::Level::Error));
724 }
725
726 #[test]
727 fn from_lookup_traffic_numeric_one() {
728 let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "1")]);
729 assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
730 assert!(summary.show_request_traffic);
731 }
732
733 #[test]
734 fn with_banner_none_clears_show_banner() {
735 let config = ConsoleConfig::new().with_banner(BannerStyle::None);
736 assert!(!config.show_banner);
737 assert_eq!(config.banner_style, BannerStyle::None);
738 }
739
740 #[test]
741 fn with_traffic_none_clears_show_request_traffic() {
742 let config = ConsoleConfig::new()
743 .with_traffic(TrafficVerbosity::Full)
744 .with_traffic(TrafficVerbosity::None);
745 assert!(!config.show_request_traffic);
746 assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
747 }
748
749 #[test]
750 fn default_fields_full_coverage() {
751 let config = ConsoleConfig::default();
752 assert!(config.log_targets);
753 assert!(!config.log_file_line);
754 assert!(config.show_error_codes);
755 assert!(!config.show_stats_periodic);
756 assert_eq!(config.stats_interval_secs, 60);
757 assert_eq!(config.max_json_depth, 5);
758 assert_eq!(config.truncate_at, 200);
759 assert!(config.show_suggestions);
760 assert!(config.custom_colors.is_none());
761 assert!(config.context.is_none());
762 assert!(config.force_color.is_none());
763 }
764}