use crate::detection::DisplayContext;
use std::env;
#[derive(Debug, Clone)]
pub struct ConsoleConfig {
pub context: Option<DisplayContext>,
pub force_color: Option<bool>,
pub force_plain: bool,
pub custom_colors: Option<CustomColors>,
pub show_banner: bool,
pub show_capabilities: bool,
pub banner_style: BannerStyle,
pub log_level: Option<log::Level>,
pub log_timestamps: bool,
pub log_targets: bool,
pub log_file_line: bool,
pub show_stats_periodic: bool,
pub stats_interval_secs: u64,
pub show_request_traffic: bool,
pub traffic_verbosity: TrafficVerbosity,
pub show_suggestions: bool,
pub show_error_codes: bool,
pub show_backtrace: bool,
pub max_table_rows: usize,
pub max_json_depth: usize,
pub truncate_at: usize,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BannerStyle {
#[default]
Full,
Compact,
Minimal,
None,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum TrafficVerbosity {
#[default]
None,
Summary,
Headers,
Full,
}
#[derive(Debug, Clone, Default)]
pub struct CustomColors {
pub primary: Option<String>,
pub secondary: Option<String>,
pub success: Option<String>,
pub warning: Option<String>,
pub error: Option<String>,
}
impl Default for ConsoleConfig {
fn default() -> Self {
Self {
context: None,
force_color: None,
force_plain: false,
custom_colors: None,
show_banner: true,
show_capabilities: true,
banner_style: BannerStyle::Full,
log_level: None,
log_timestamps: true,
log_targets: true,
log_file_line: false,
show_stats_periodic: false,
stats_interval_secs: 60,
show_request_traffic: false,
traffic_verbosity: TrafficVerbosity::None,
show_suggestions: true,
show_error_codes: true,
show_backtrace: false,
max_table_rows: 100,
max_json_depth: 5,
truncate_at: 200,
}
}
}
impl ConsoleConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn from_env() -> Self {
Self::from_lookup(|key| env::var(key).ok())
}
fn from_lookup<F>(lookup: F) -> Self
where
F: Fn(&str) -> Option<String>,
{
let mut config = Self::default();
if lookup("FASTMCP_FORCE_COLOR").is_some() {
config.force_color = Some(true);
}
if lookup("FASTMCP_PLAIN").is_some() || lookup("NO_COLOR").is_some() {
config.force_plain = true;
}
if let Some(val) = lookup("FASTMCP_BANNER") {
config.banner_style = match val.to_lowercase().as_str() {
"compact" => BannerStyle::Compact,
"minimal" => BannerStyle::Minimal,
"none" | "0" | "false" => BannerStyle::None,
_ => BannerStyle::Full,
};
config.show_banner = !matches!(config.banner_style, BannerStyle::None);
}
if let Some(level) = lookup("FASTMCP_LOG") {
config.log_level = match level.to_lowercase().as_str() {
"trace" => Some(log::Level::Trace),
"debug" => Some(log::Level::Debug),
"info" => Some(log::Level::Info),
"warn" | "warning" => Some(log::Level::Warn),
"error" => Some(log::Level::Error),
_ => None,
};
}
if lookup("FASTMCP_LOG_TIMESTAMPS")
.map(|v| v == "0" || v.to_lowercase() == "false")
.unwrap_or(false)
{
config.log_timestamps = false;
}
if let Some(val) = lookup("FASTMCP_TRAFFIC") {
config.traffic_verbosity = match val.to_lowercase().as_str() {
"summary" | "1" => TrafficVerbosity::Summary,
"headers" | "2" => TrafficVerbosity::Headers,
"full" | "3" => TrafficVerbosity::Full,
_ => TrafficVerbosity::None,
};
config.show_request_traffic =
!matches!(config.traffic_verbosity, TrafficVerbosity::None);
}
if lookup("RUST_BACKTRACE").is_some() {
config.show_backtrace = true;
}
config
}
#[must_use]
pub fn force_color(mut self, force: bool) -> Self {
self.force_color = Some(force);
self
}
#[must_use]
pub fn plain_mode(mut self) -> Self {
self.force_plain = true;
self
}
#[must_use]
pub fn with_banner(mut self, style: BannerStyle) -> Self {
self.banner_style = style;
self.show_banner = !matches!(style, BannerStyle::None);
self
}
#[must_use]
pub fn without_banner(mut self) -> Self {
self.show_banner = false;
self.banner_style = BannerStyle::None;
self
}
#[must_use]
pub fn with_log_level(mut self, level: log::Level) -> Self {
self.log_level = Some(level);
self
}
#[must_use]
pub fn with_traffic(mut self, verbosity: TrafficVerbosity) -> Self {
self.traffic_verbosity = verbosity;
self.show_request_traffic = !matches!(verbosity, TrafficVerbosity::None);
self
}
#[must_use]
pub fn with_periodic_stats(mut self, interval_secs: u64) -> Self {
self.show_stats_periodic = true;
self.stats_interval_secs = interval_secs;
self
}
#[must_use]
pub fn without_suggestions(mut self) -> Self {
self.show_suggestions = false;
self
}
#[must_use]
pub fn with_custom_colors(mut self, colors: CustomColors) -> Self {
self.custom_colors = Some(colors);
self
}
#[must_use]
pub fn with_context(mut self, context: DisplayContext) -> Self {
self.context = Some(context);
self
}
#[must_use]
pub fn with_max_table_rows(mut self, max: usize) -> Self {
self.max_table_rows = max;
self
}
#[must_use]
pub fn with_max_json_depth(mut self, max: usize) -> Self {
self.max_json_depth = max;
self
}
#[must_use]
pub fn with_truncate_at(mut self, len: usize) -> Self {
self.truncate_at = len;
self
}
#[must_use]
pub fn theme(&self) -> &'static crate::theme::FastMcpTheme {
crate::theme::theme()
}
#[must_use]
pub fn resolve_context(&self) -> DisplayContext {
if self.force_plain {
return DisplayContext::new_agent();
}
if let Some(true) = self.force_color {
return DisplayContext::new_human();
}
self.context.unwrap_or_else(DisplayContext::detect)
}
#[must_use]
pub fn should_use_rich(&self) -> bool {
self.resolve_context().is_human()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn config_from_pairs(pairs: &[(&str, &str)]) -> ConsoleConfig {
let map: HashMap<&str, &str> = pairs.iter().copied().collect();
ConsoleConfig::from_lookup(|key| map.get(key).map(|v| (*v).to_string()))
}
#[test]
fn test_default_config() {
let config = ConsoleConfig::new();
assert!(config.show_banner);
assert!(config.show_capabilities);
assert_eq!(config.banner_style, BannerStyle::Full);
assert!(config.log_timestamps);
assert!(!config.force_plain);
assert_eq!(config.max_table_rows, 100);
}
#[test]
fn test_builder_pattern() {
let config = ConsoleConfig::new()
.with_banner(BannerStyle::Compact)
.with_log_level(log::Level::Debug)
.with_traffic(TrafficVerbosity::Summary)
.with_periodic_stats(30);
assert_eq!(config.banner_style, BannerStyle::Compact);
assert_eq!(config.log_level, Some(log::Level::Debug));
assert_eq!(config.traffic_verbosity, TrafficVerbosity::Summary);
assert!(config.show_stats_periodic);
assert_eq!(config.stats_interval_secs, 30);
}
#[test]
fn test_plain_mode() {
let config = ConsoleConfig::new().plain_mode();
assert!(config.force_plain);
assert_eq!(config.resolve_context(), DisplayContext::Agent);
}
#[test]
fn test_force_color() {
let config = ConsoleConfig::new().force_color(true);
assert_eq!(config.force_color, Some(true));
assert_eq!(config.resolve_context(), DisplayContext::Human);
}
#[test]
fn test_without_banner() {
let config = ConsoleConfig::new().without_banner();
assert!(!config.show_banner);
assert_eq!(config.banner_style, BannerStyle::None);
}
#[test]
fn test_from_lookup_defaults_when_empty() {
let config = config_from_pairs(&[]);
assert_eq!(config.banner_style, BannerStyle::Full);
assert_eq!(config.log_level, None);
assert!(config.log_timestamps);
assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
assert!(!config.show_request_traffic);
assert!(!config.show_backtrace);
}
#[test]
fn test_from_lookup_display_mode_flags() {
let config = config_from_pairs(&[("FASTMCP_FORCE_COLOR", "1"), ("FASTMCP_PLAIN", "1")]);
assert_eq!(config.force_color, Some(true));
assert!(config.force_plain);
let no_color = config_from_pairs(&[("NO_COLOR", "1")]);
assert!(no_color.force_plain);
}
#[test]
fn test_from_lookup_banner_variants() {
let compact = config_from_pairs(&[("FASTMCP_BANNER", "compact")]);
assert_eq!(compact.banner_style, BannerStyle::Compact);
assert!(compact.show_banner);
let minimal = config_from_pairs(&[("FASTMCP_BANNER", "minimal")]);
assert_eq!(minimal.banner_style, BannerStyle::Minimal);
assert!(minimal.show_banner);
let none_false = config_from_pairs(&[("FASTMCP_BANNER", "false")]);
assert_eq!(none_false.banner_style, BannerStyle::None);
assert!(!none_false.show_banner);
let none_zero = config_from_pairs(&[("FASTMCP_BANNER", "0")]);
assert_eq!(none_zero.banner_style, BannerStyle::None);
assert!(!none_zero.show_banner);
let fallback = config_from_pairs(&[("FASTMCP_BANNER", "unknown")]);
assert_eq!(fallback.banner_style, BannerStyle::Full);
assert!(fallback.show_banner);
}
#[test]
fn test_from_lookup_log_levels_and_timestamp_toggle() {
let trace = config_from_pairs(&[("FASTMCP_LOG", "trace")]);
assert_eq!(trace.log_level, Some(log::Level::Trace));
let debug = config_from_pairs(&[("FASTMCP_LOG", "debug")]);
assert_eq!(debug.log_level, Some(log::Level::Debug));
let warn_alias = config_from_pairs(&[("FASTMCP_LOG", "warning")]);
assert_eq!(warn_alias.log_level, Some(log::Level::Warn));
let invalid = config_from_pairs(&[("FASTMCP_LOG", "verbose")]);
assert_eq!(invalid.log_level, None);
let timestamps_disabled_zero = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "0")]);
assert!(!timestamps_disabled_zero.log_timestamps);
let timestamps_disabled_false = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "false")]);
assert!(!timestamps_disabled_false.log_timestamps);
let timestamps_enabled = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "1")]);
assert!(timestamps_enabled.log_timestamps);
}
#[test]
fn test_from_lookup_traffic_variants_and_backtrace() {
let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "summary")]);
assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
assert!(summary.show_request_traffic);
let headers = config_from_pairs(&[("FASTMCP_TRAFFIC", "2")]);
assert_eq!(headers.traffic_verbosity, TrafficVerbosity::Headers);
assert!(headers.show_request_traffic);
let full = config_from_pairs(&[("FASTMCP_TRAFFIC", "3")]);
assert_eq!(full.traffic_verbosity, TrafficVerbosity::Full);
assert!(full.show_request_traffic);
let none = config_from_pairs(&[("FASTMCP_TRAFFIC", "none")]);
assert_eq!(none.traffic_verbosity, TrafficVerbosity::None);
assert!(!none.show_request_traffic);
let unknown = config_from_pairs(&[("FASTMCP_TRAFFIC", "loud")]);
assert_eq!(unknown.traffic_verbosity, TrafficVerbosity::None);
assert!(!unknown.show_request_traffic);
let backtrace = config_from_pairs(&[("RUST_BACKTRACE", "full")]);
assert!(backtrace.show_backtrace);
}
#[test]
fn test_additional_builder_methods_and_accessors() {
let custom = CustomColors {
primary: Some("#123456".to_string()),
secondary: None,
success: Some("#22aa22".to_string()),
warning: None,
error: Some("#ff0000".to_string()),
};
let config = ConsoleConfig::new()
.without_suggestions()
.with_custom_colors(custom.clone())
.with_context(DisplayContext::new_agent())
.with_max_table_rows(50)
.with_max_json_depth(3)
.with_truncate_at(80);
assert!(!config.show_suggestions);
assert!(config.custom_colors.is_some());
assert_eq!(
config
.custom_colors
.as_ref()
.and_then(|c| c.primary.as_deref()),
Some("#123456")
);
assert_eq!(config.context, Some(DisplayContext::Agent));
assert_eq!(config.max_table_rows, 50);
assert_eq!(config.max_json_depth, 3);
assert_eq!(config.truncate_at, 80);
assert!(std::ptr::eq(config.theme(), crate::theme::theme()));
}
#[test]
fn test_context_resolution_and_should_use_rich() {
let plain = ConsoleConfig::new().plain_mode();
assert_eq!(plain.resolve_context(), DisplayContext::Agent);
assert!(!plain.should_use_rich());
let forced_rich = ConsoleConfig::new().force_color(true);
assert_eq!(forced_rich.resolve_context(), DisplayContext::Human);
assert!(forced_rich.should_use_rich());
let explicit_agent = ConsoleConfig::new().with_context(DisplayContext::new_agent());
assert_eq!(explicit_agent.resolve_context(), DisplayContext::Agent);
assert!(!explicit_agent.should_use_rich());
let explicit_human = ConsoleConfig::new().with_context(DisplayContext::new_human());
assert_eq!(explicit_human.resolve_context(), DisplayContext::Human);
assert!(explicit_human.should_use_rich());
}
#[test]
fn test_builder_methods_via_fn_pointers() {
let set_force_color: fn(ConsoleConfig, bool) -> ConsoleConfig = ConsoleConfig::force_color;
let set_banner: fn(ConsoleConfig, BannerStyle) -> ConsoleConfig =
ConsoleConfig::with_banner;
let set_log: fn(ConsoleConfig, log::Level) -> ConsoleConfig = ConsoleConfig::with_log_level;
let set_traffic: fn(ConsoleConfig, TrafficVerbosity) -> ConsoleConfig =
ConsoleConfig::with_traffic;
let set_stats: fn(ConsoleConfig, u64) -> ConsoleConfig = ConsoleConfig::with_periodic_stats;
let disable_suggestions: fn(ConsoleConfig) -> ConsoleConfig =
ConsoleConfig::without_suggestions;
let set_custom: fn(ConsoleConfig, CustomColors) -> ConsoleConfig =
ConsoleConfig::with_custom_colors;
let set_context: fn(ConsoleConfig, DisplayContext) -> ConsoleConfig =
ConsoleConfig::with_context;
let set_rows: fn(ConsoleConfig, usize) -> ConsoleConfig =
ConsoleConfig::with_max_table_rows;
let set_depth: fn(ConsoleConfig, usize) -> ConsoleConfig =
ConsoleConfig::with_max_json_depth;
let set_truncate: fn(ConsoleConfig, usize) -> ConsoleConfig =
ConsoleConfig::with_truncate_at;
let custom = CustomColors {
primary: Some("#111111".to_string()),
secondary: Some("#222222".to_string()),
success: None,
warning: Some("#ffaa00".to_string()),
error: None,
};
let config = set_truncate(
set_depth(
set_rows(
set_context(
set_custom(
disable_suggestions(set_stats(
set_traffic(
set_log(
set_banner(
set_force_color(ConsoleConfig::new(), false),
BannerStyle::None,
),
log::Level::Error,
),
TrafficVerbosity::Headers,
),
15,
)),
custom.clone(),
),
DisplayContext::new_human(),
),
12,
),
7,
),
42,
);
assert_eq!(config.force_color, Some(false));
assert_eq!(config.banner_style, BannerStyle::None);
assert!(!config.show_banner);
assert_eq!(config.log_level, Some(log::Level::Error));
assert_eq!(config.traffic_verbosity, TrafficVerbosity::Headers);
assert!(config.show_request_traffic);
assert!(config.show_stats_periodic);
assert_eq!(config.stats_interval_secs, 15);
assert!(!config.show_suggestions);
assert_eq!(
config
.custom_colors
.as_ref()
.and_then(|c| c.secondary.as_deref()),
Some("#222222")
);
assert_eq!(config.context, Some(DisplayContext::Human));
assert_eq!(config.max_table_rows, 12);
assert_eq!(config.max_json_depth, 7);
assert_eq!(config.truncate_at, 42);
}
#[test]
fn test_from_env_and_fallback_context_resolution_paths() {
let _ = ConsoleConfig::from_env();
let forced_false = ConsoleConfig::new()
.force_color(false)
.with_context(DisplayContext::new_agent());
assert_eq!(forced_false.resolve_context(), DisplayContext::Agent);
assert!(!forced_false.should_use_rich());
let explicit_human = ConsoleConfig::new()
.force_color(false)
.with_context(DisplayContext::new_human());
assert_eq!(explicit_human.resolve_context(), DisplayContext::Human);
assert!(explicit_human.should_use_rich());
}
#[test]
fn banner_style_and_traffic_verbosity_defaults() {
assert_eq!(BannerStyle::default(), BannerStyle::Full);
assert_eq!(TrafficVerbosity::default(), TrafficVerbosity::None);
}
#[test]
fn console_config_debug_and_clone() {
let config = ConsoleConfig::new()
.with_log_level(log::Level::Info)
.with_max_table_rows(42);
let debug = format!("{config:?}");
assert!(debug.contains("ConsoleConfig"));
assert!(debug.contains("42"));
let cloned = config.clone();
assert_eq!(cloned.max_table_rows, 42);
assert_eq!(cloned.log_level, Some(log::Level::Info));
}
#[test]
fn custom_colors_default_all_none() {
let colors = CustomColors::default();
assert!(colors.primary.is_none());
assert!(colors.secondary.is_none());
assert!(colors.success.is_none());
assert!(colors.warning.is_none());
assert!(colors.error.is_none());
let debug = format!("{colors:?}");
assert!(debug.contains("CustomColors"));
}
#[test]
fn from_lookup_banner_none_literal_and_full_explicit() {
let none = config_from_pairs(&[("FASTMCP_BANNER", "none")]);
assert_eq!(none.banner_style, BannerStyle::None);
assert!(!none.show_banner);
let full = config_from_pairs(&[("FASTMCP_BANNER", "full")]);
assert_eq!(full.banner_style, BannerStyle::Full);
assert!(full.show_banner);
}
#[test]
fn from_lookup_remaining_log_levels() {
let info = config_from_pairs(&[("FASTMCP_LOG", "info")]);
assert_eq!(info.log_level, Some(log::Level::Info));
let warn = config_from_pairs(&[("FASTMCP_LOG", "warn")]);
assert_eq!(warn.log_level, Some(log::Level::Warn));
let error = config_from_pairs(&[("FASTMCP_LOG", "error")]);
assert_eq!(error.log_level, Some(log::Level::Error));
}
#[test]
fn from_lookup_traffic_numeric_one() {
let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "1")]);
assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
assert!(summary.show_request_traffic);
}
#[test]
fn with_banner_none_clears_show_banner() {
let config = ConsoleConfig::new().with_banner(BannerStyle::None);
assert!(!config.show_banner);
assert_eq!(config.banner_style, BannerStyle::None);
}
#[test]
fn with_traffic_none_clears_show_request_traffic() {
let config = ConsoleConfig::new()
.with_traffic(TrafficVerbosity::Full)
.with_traffic(TrafficVerbosity::None);
assert!(!config.show_request_traffic);
assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
}
#[test]
fn default_fields_full_coverage() {
let config = ConsoleConfig::default();
assert!(config.log_targets);
assert!(!config.log_file_line);
assert!(config.show_error_codes);
assert!(!config.show_stats_periodic);
assert_eq!(config.stats_interval_secs, 60);
assert_eq!(config.max_json_depth, 5);
assert_eq!(config.truncate_at, 200);
assert!(config.show_suggestions);
assert!(config.custom_colors.is_none());
assert!(config.context.is_none());
assert!(config.force_color.is_none());
}
}