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 show_banner: bool,
pub show_capabilities: bool,
pub banner_style: BannerStyle,
pub log_level: log::LevelFilter,
pub log_timestamps: bool,
pub log_targets: bool,
pub log_file_line: 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,
Full,
}
impl Default for ConsoleConfig {
fn default() -> Self {
Self {
context: None,
force_color: None,
force_plain: false,
show_banner: true,
show_capabilities: true,
banner_style: BannerStyle::Full,
log_level: log::LevelFilter::Info,
log_timestamps: true,
log_targets: true,
log_file_line: 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 lookup("FASTMCP_NO_BANNER").is_some_and(|value| {
matches!(
value.to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
}) {
config.show_banner = false;
config.banner_style = BannerStyle::None;
}
if let Some(level) = lookup("FASTMCP_LOG") {
config.log_level = match level.to_lowercase().as_str() {
"off" => log::LevelFilter::Off,
"trace" => log::LevelFilter::Trace,
"debug" => log::LevelFilter::Debug,
"info" => log::LevelFilter::Info,
"warn" | "warning" => log::LevelFilter::Warn,
"error" => log::LevelFilter::Error,
_ => config.log_level,
};
}
if lookup("FASTMCP_LOG_TIMESTAMPS")
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
.unwrap_or(false)
{
config.log_timestamps = false;
}
if lookup("FASTMCP_LOG_TARGETS")
.map(|v| matches!(v.to_ascii_lowercase().as_str(), "0" | "false" | "no"))
.unwrap_or(false)
{
config.log_targets = false;
}
config.log_file_line = lookup("FASTMCP_LOG_FILE_LINE")
.is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
if let Some(val) = lookup("FASTMCP_TRAFFIC") {
config.traffic_verbosity = match val.to_lowercase().as_str() {
"summary" | "1" => TrafficVerbosity::Summary,
"full" | "2" => TrafficVerbosity::Full,
_ => TrafficVerbosity::None,
};
}
config.show_backtrace = lookup("RUST_BACKTRACE")
.is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "1" | "full"));
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 = level.to_level_filter();
self
}
#[must_use]
pub fn with_log_level_filter(mut self, level: log::LevelFilter) -> Self {
self.log_level = level;
self
}
#[must_use]
pub fn with_traffic(mut self, verbosity: TrafficVerbosity) -> Self {
self.traffic_verbosity = verbosity;
self
}
#[must_use]
pub fn without_suggestions(mut self) -> Self {
self.show_suggestions = false;
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(force_color) = self.force_color {
return if force_color {
DisplayContext::new_human()
} else {
DisplayContext::new_agent()
};
}
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);
assert_eq!(config.banner_style, BannerStyle::Compact);
assert_eq!(config.log_level, log::LevelFilter::Debug);
assert_eq!(config.traffic_verbosity, TrafficVerbosity::Summary);
}
#[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, log::LevelFilter::Info);
assert!(config.log_timestamps);
assert_eq!(config.traffic_verbosity, TrafficVerbosity::None);
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);
assert_eq!(config.resolve_context(), DisplayContext::Agent);
assert!(!config.should_use_rich());
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);
for truthy in ["1", "true", "yes", "on"] {
let disabled =
config_from_pairs(&[("FASTMCP_BANNER", "full"), ("FASTMCP_NO_BANNER", truthy)]);
assert_eq!(disabled.banner_style, BannerStyle::None);
assert!(!disabled.show_banner);
}
for falsey in ["0", "false", "no", "off", ""] {
let enabled = config_from_pairs(&[("FASTMCP_NO_BANNER", falsey)]);
assert_eq!(enabled.banner_style, BannerStyle::Full);
assert!(enabled.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, log::LevelFilter::Trace);
let debug = config_from_pairs(&[("FASTMCP_LOG", "debug")]);
assert_eq!(debug.log_level, log::LevelFilter::Debug);
let warn_alias = config_from_pairs(&[("FASTMCP_LOG", "warning")]);
assert_eq!(warn_alias.log_level, log::LevelFilter::Warn);
let off = config_from_pairs(&[("FASTMCP_LOG", "off")]);
assert_eq!(off.log_level, log::LevelFilter::Off);
let invalid = config_from_pairs(&[("FASTMCP_LOG", "verbose")]);
assert_eq!(invalid.log_level, log::LevelFilter::Info);
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_disabled_no = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "no")]);
assert!(!timestamps_disabled_no.log_timestamps);
let timestamps_enabled = config_from_pairs(&[("FASTMCP_LOG_TIMESTAMPS", "1")]);
assert!(timestamps_enabled.log_timestamps);
let targets_disabled = config_from_pairs(&[("FASTMCP_LOG_TARGETS", "no")]);
assert!(!targets_disabled.log_targets);
let file_line_enabled = config_from_pairs(&[("FASTMCP_LOG_FILE_LINE", "yes")]);
assert!(file_line_enabled.log_file_line);
}
#[test]
fn test_from_lookup_traffic_variants_and_backtrace() {
let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "summary")]);
assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
let full = config_from_pairs(&[("FASTMCP_TRAFFIC", "2")]);
assert_eq!(full.traffic_verbosity, TrafficVerbosity::Full);
let none = config_from_pairs(&[("FASTMCP_TRAFFIC", "none")]);
assert_eq!(none.traffic_verbosity, TrafficVerbosity::None);
let unknown = config_from_pairs(&[("FASTMCP_TRAFFIC", "loud")]);
assert_eq!(unknown.traffic_verbosity, TrafficVerbosity::None);
let backtrace = config_from_pairs(&[("RUST_BACKTRACE", "full")]);
assert!(backtrace.show_backtrace);
let disabled_backtrace = config_from_pairs(&[("RUST_BACKTRACE", "0")]);
assert!(!disabled_backtrace.show_backtrace);
}
#[test]
fn test_additional_builder_methods_and_accessors() {
let config = ConsoleConfig::new()
.without_suggestions()
.with_context(DisplayContext::new_agent())
.with_max_table_rows(50)
.with_max_json_depth(3)
.with_truncate_at(80);
assert!(!config.show_suggestions);
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 disable_suggestions: fn(ConsoleConfig) -> ConsoleConfig =
ConsoleConfig::without_suggestions;
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 config = set_truncate(
set_depth(
set_rows(
set_context(
disable_suggestions(set_traffic(
set_log(
set_banner(
set_force_color(ConsoleConfig::new(), false),
BannerStyle::None,
),
log::Level::Error,
),
TrafficVerbosity::Full,
)),
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, log::LevelFilter::Error);
assert_eq!(config.traffic_verbosity, TrafficVerbosity::Full);
assert!(!config.show_suggestions);
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::Agent);
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, log::LevelFilter::Info);
}
#[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, log::LevelFilter::Info);
let warn = config_from_pairs(&[("FASTMCP_LOG", "warn")]);
assert_eq!(warn.log_level, log::LevelFilter::Warn);
let error = config_from_pairs(&[("FASTMCP_LOG", "error")]);
assert_eq!(error.log_level, log::LevelFilter::Error);
}
#[test]
fn from_lookup_traffic_numeric_one() {
let summary = config_from_pairs(&[("FASTMCP_TRAFFIC", "1")]);
assert_eq!(summary.traffic_verbosity, TrafficVerbosity::Summary);
}
#[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_disables_traffic() {
let config = ConsoleConfig::new()
.with_traffic(TrafficVerbosity::Full)
.with_traffic(TrafficVerbosity::None);
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_eq!(config.max_json_depth, 5);
assert_eq!(config.truncate_at, 200);
assert!(config.show_suggestions);
assert!(config.context.is_none());
assert!(config.force_color.is_none());
}
}