mcp_execution_files/types.rs
1//! Core types for the virtual filesystem.
2//!
3//! This module defines strong types for VFS paths, files, and errors,
4//! following Microsoft Rust Guidelines for type safety and error handling.
5//!
6//! # Examples
7//!
8//! ```
9//! use mcp_execution_files::{FilePath, FileEntry};
10//!
11//! let path = FilePath::new("/mcp-tools/servers/github/manifest.json").unwrap();
12//! let file = FileEntry::new("{}");
13//!
14//! assert_eq!(path.as_str(), "/mcp-tools/servers/github/manifest.json");
15//! assert_eq!(file.content(), "{}");
16//! ```
17
18use std::fmt;
19use std::path::Path;
20use thiserror::Error;
21
22/// Errors that can occur during VFS operations.
23///
24/// All error variants include contextual information and implement
25/// `is_xxx()` methods for easy error classification.
26///
27/// # Examples
28///
29/// ```
30/// use mcp_execution_files::FilesError;
31///
32/// let error = FilesError::FileNotFound {
33/// path: "/missing.txt".to_string(),
34/// };
35///
36/// assert!(error.is_not_found());
37/// ```
38#[derive(Error, Debug)]
39pub enum FilesError {
40 /// File or directory not found at the specified path
41 #[error("File not found: {path}")]
42 FileNotFound {
43 /// The path that was not found
44 path: String,
45 },
46
47 /// Path exists but is not a directory
48 #[error("Not a directory: {path}")]
49 NotADirectory {
50 /// The path that is not a directory
51 path: String,
52 },
53
54 /// Path is invalid or malformed
55 #[error("Invalid path: {path}")]
56 InvalidPath {
57 /// The invalid path
58 path: String,
59 },
60
61 /// Path is not absolute (must start with '/')
62 #[error("Path must be absolute: {path}")]
63 PathNotAbsolute {
64 /// The relative path
65 path: String,
66 },
67
68 /// Path contains invalid components (e.g., '..')
69 #[error("Path contains invalid components: {path}")]
70 InvalidPathComponent {
71 /// The path with invalid components
72 path: String,
73 },
74
75 /// I/O operation failed during filesystem export
76 #[error("I/O error at {path}: {source}")]
77 IoError {
78 /// The path where the I/O error occurred
79 path: String,
80 /// The underlying I/O error
81 source: std::io::Error,
82 },
83}
84
85impl FilesError {
86 /// Returns `true` if this is a file not found error.
87 ///
88 /// # Examples
89 ///
90 /// ```
91 /// use mcp_execution_files::FilesError;
92 ///
93 /// let error = FilesError::FileNotFound {
94 /// path: "/test.txt".to_string(),
95 /// };
96 ///
97 /// assert!(error.is_not_found());
98 /// ```
99 #[must_use]
100 pub const fn is_not_found(&self) -> bool {
101 matches!(self, Self::FileNotFound { .. })
102 }
103
104 /// Returns `true` if this is a not-a-directory error.
105 ///
106 /// # Examples
107 ///
108 /// ```
109 /// use mcp_execution_files::FilesError;
110 ///
111 /// let error = FilesError::NotADirectory {
112 /// path: "/file.txt".to_string(),
113 /// };
114 ///
115 /// assert!(error.is_not_directory());
116 /// ```
117 #[must_use]
118 pub const fn is_not_directory(&self) -> bool {
119 matches!(self, Self::NotADirectory { .. })
120 }
121
122 /// Returns `true` if this is an invalid path error.
123 ///
124 /// # Examples
125 ///
126 /// ```
127 /// use mcp_execution_files::FilesError;
128 ///
129 /// let error = FilesError::InvalidPath {
130 /// path: "".to_string(),
131 /// };
132 ///
133 /// assert!(error.is_invalid_path());
134 /// ```
135 #[must_use]
136 pub const fn is_invalid_path(&self) -> bool {
137 matches!(
138 self,
139 Self::InvalidPath { .. }
140 | Self::PathNotAbsolute { .. }
141 | Self::InvalidPathComponent { .. }
142 )
143 }
144
145 /// Returns `true` if this is an I/O error.
146 ///
147 /// # Examples
148 ///
149 /// ```
150 /// use mcp_execution_files::FilesError;
151 /// use std::io;
152 ///
153 /// let error = FilesError::IoError {
154 /// path: "/test.ts".to_string(),
155 /// source: io::Error::from(io::ErrorKind::PermissionDenied),
156 /// };
157 ///
158 /// assert!(error.is_io_error());
159 /// ```
160 #[must_use]
161 pub const fn is_io_error(&self) -> bool {
162 matches!(self, Self::IoError { .. })
163 }
164}
165
166/// A validated virtual filesystem path.
167///
168/// `FilePath` ensures paths use Unix-style conventions on all platforms:
169/// - Must start with '/' (absolute paths only)
170/// - Free of parent directory references ('..')
171/// - Use forward slashes as separators
172///
173/// This is intentional: VFS paths are platform-independent and always use
174/// Unix conventions, even on Windows. This enables consistent path handling
175/// across development machines and CI environments.
176///
177/// # Examples
178///
179/// ```
180/// use mcp_execution_files::FilePath;
181///
182/// let path = FilePath::new("/mcp-tools/servers/test/file.ts").unwrap();
183/// assert_eq!(path.as_str(), "/mcp-tools/servers/test/file.ts");
184/// ```
185///
186/// ```
187/// use mcp_execution_files::FilePath;
188///
189/// // Invalid paths are rejected
190/// assert!(FilePath::new("relative/path").is_err());
191/// assert!(FilePath::new("/parent/../escape").is_err());
192/// ```
193///
194/// On Windows, Unix-style paths like "/mcp-tools/servers/test" are accepted
195/// (not Windows paths like "C:\mcp-tools\servers\test").
196#[derive(Debug, Clone, PartialEq, Eq, Hash)]
197pub struct FilePath(String);
198
199impl FilePath {
200 /// Creates a new `FilePath` from a path-like type.
201 ///
202 /// The path must be absolute (start with '/') and must not contain parent
203 /// directory references ('..').
204 ///
205 /// `FilePath` uses Unix-style path conventions on all platforms, ensuring
206 /// consistent behavior on Linux, macOS, and Windows. Paths are validated
207 /// using string-based checks rather than platform-specific `Path::is_absolute()`,
208 /// which enables cross-platform compatibility.
209 ///
210 /// # Errors
211 ///
212 /// Returns `FilesError::PathNotAbsolute` if the path does not start with '/'.
213 /// Returns `FilesError::InvalidPathComponent` if the path contains '..'.
214 /// Returns `FilesError::InvalidPath` if the path is empty or not UTF-8 valid.
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// use mcp_execution_files::FilePath;
220 ///
221 /// let path = FilePath::new("/mcp-tools/test.ts")?;
222 /// assert_eq!(path.as_str(), "/mcp-tools/test.ts");
223 ///
224 /// // Works on all platforms (Unix-style paths)
225 /// let path = FilePath::new("/mcp-tools/servers/test/manifest.json")?;
226 /// # Ok::<(), mcp_execution_files::FilesError>(())
227 /// ```
228 pub fn new(path: impl AsRef<Path>) -> Result<Self> {
229 let path = path.as_ref();
230
231 // Convert to string for platform-independent validation
232 let path_str = path.to_str().ok_or_else(|| FilesError::InvalidPath {
233 path: path.display().to_string(),
234 })?;
235
236 // Normalize path separators to Unix-style (forward slashes) on all platforms
237 // This ensures VFS paths are consistent regardless of the host OS
238 let normalized_str = if cfg!(target_os = "windows") {
239 // Replace Windows backslashes with forward slashes
240 path_str.replace(std::path::MAIN_SEPARATOR, "/")
241 } else {
242 path_str.to_string()
243 };
244
245 // Check if empty
246 if normalized_str.is_empty() {
247 return Err(FilesError::InvalidPath {
248 path: String::new(),
249 });
250 }
251
252 // Check if absolute using Unix-style path rules (starts with '/')
253 // VFS uses Unix-style paths on all platforms
254 if !normalized_str.starts_with('/') {
255 return Err(FilesError::PathNotAbsolute {
256 path: normalized_str,
257 });
258 }
259
260 // Check for '..' components in the path string
261 if normalized_str.contains("..") {
262 return Err(FilesError::InvalidPathComponent {
263 path: normalized_str,
264 });
265 }
266
267 // Store as String with normalized Unix-style separators
268 Ok(Self(normalized_str))
269 }
270
271 /// Returns the path as a `Path` reference.
272 ///
273 /// # Examples
274 ///
275 /// ```
276 /// use mcp_execution_files::FilePath;
277 ///
278 /// let vfs_path = FilePath::new("/test.ts")?;
279 /// let path = vfs_path.as_path();
280 /// assert_eq!(path.to_str(), Some("/test.ts"));
281 /// # Ok::<(), mcp_execution_files::FilesError>(())
282 /// ```
283 #[must_use]
284 pub fn as_path(&self) -> &Path {
285 Path::new(&self.0)
286 }
287
288 /// Returns the path as a string slice.
289 ///
290 /// # Examples
291 ///
292 /// ```
293 /// use mcp_execution_files::FilePath;
294 ///
295 /// let path = FilePath::new("/mcp-tools/file.ts")?;
296 /// assert_eq!(path.as_str(), "/mcp-tools/file.ts");
297 /// # Ok::<(), mcp_execution_files::FilesError>(())
298 /// ```
299 #[must_use]
300 pub fn as_str(&self) -> &str {
301 &self.0
302 }
303
304 /// Returns the parent directory of this path.
305 ///
306 /// Returns `None` if this is the root path.
307 ///
308 /// # Examples
309 ///
310 /// ```
311 /// use mcp_execution_files::FilePath;
312 ///
313 /// let path = FilePath::new("/mcp-tools/servers/test.ts")?;
314 /// let parent = path.parent().unwrap();
315 /// assert_eq!(parent.as_str(), "/mcp-tools/servers");
316 /// # Ok::<(), mcp_execution_files::FilesError>(())
317 /// ```
318 #[must_use]
319 pub fn parent(&self) -> Option<Self> {
320 // Find the last '/' separator
321 self.0.rfind('/').map(|pos| {
322 if pos == 0 {
323 // Parent of "/foo" is "/" (root)
324 Self("/".to_string())
325 } else {
326 // Parent of "/foo/bar" is "/foo"
327 Self(self.0[..pos].to_string())
328 }
329 })
330 }
331
332 /// Checks if this path is a directory path.
333 ///
334 /// A path is considered a directory if it does not have a file extension.
335 ///
336 /// # Examples
337 ///
338 /// ```
339 /// use mcp_execution_files::FilePath;
340 ///
341 /// let dir = FilePath::new("/mcp-tools/servers")?;
342 /// assert!(dir.is_dir_path());
343 ///
344 /// let file = FilePath::new("/mcp-tools/manifest.json")?;
345 /// assert!(!file.is_dir_path());
346 /// # Ok::<(), mcp_execution_files::FilesError>(())
347 /// ```
348 #[must_use]
349 pub fn is_dir_path(&self) -> bool {
350 // A path is a directory if it doesn't contain a '.' after the last '/'
351 self.0
352 .rfind('/')
353 .is_some_and(|last_slash| !self.0[last_slash..].contains('.'))
354 }
355}
356
357impl fmt::Display for FilePath {
358 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
359 write!(f, "{}", self.as_str())
360 }
361}
362
363impl AsRef<Path> for FilePath {
364 fn as_ref(&self) -> &Path {
365 Path::new(&self.0)
366 }
367}
368
369/// A file in the virtual filesystem.
370///
371/// Contains file content as a string and metadata.
372///
373/// # Examples
374///
375/// ```
376/// use mcp_execution_files::FileEntry;
377///
378/// let file = FileEntry::new("console.log('hello');");
379/// assert_eq!(file.content(), "console.log('hello');");
380/// assert_eq!(file.size(), 21);
381/// ```
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct FileEntry {
384 content: String,
385}
386
387impl FileEntry {
388 /// Creates a new VFS file with the given content.
389 ///
390 /// # Examples
391 ///
392 /// ```
393 /// use mcp_execution_files::FileEntry;
394 ///
395 /// let file = FileEntry::new("export const VERSION = '1.0';");
396 /// assert_eq!(file.size(), 29);
397 /// ```
398 #[must_use]
399 pub fn new(content: impl Into<String>) -> Self {
400 Self {
401 content: content.into(),
402 }
403 }
404
405 /// Returns the file content as a string slice.
406 ///
407 /// # Examples
408 ///
409 /// ```
410 /// use mcp_execution_files::FileEntry;
411 ///
412 /// let file = FileEntry::new("test content");
413 /// assert_eq!(file.content(), "test content");
414 /// ```
415 #[must_use]
416 pub fn content(&self) -> &str {
417 &self.content
418 }
419
420 /// Returns the size of the file content in bytes.
421 ///
422 /// # Examples
423 ///
424 /// ```
425 /// use mcp_execution_files::FileEntry;
426 ///
427 /// let file = FileEntry::new("hello");
428 /// assert_eq!(file.size(), 5);
429 /// ```
430 #[must_use]
431 pub const fn size(&self) -> usize {
432 self.content.len()
433 }
434}
435
436/// Type alias for VFS operation results.
437///
438/// # Examples
439///
440/// ```
441/// use mcp_execution_files::{Result, FilePath};
442///
443/// fn validate_path(path: &str) -> Result<FilePath> {
444/// FilePath::new(path)
445/// }
446/// ```
447pub type Result<T> = std::result::Result<T, FilesError>;
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn test_vfs_path_new_valid() {
455 let path = FilePath::new("/mcp-tools/test.ts").unwrap();
456 assert_eq!(path.as_str(), "/mcp-tools/test.ts");
457 }
458
459 #[test]
460 fn test_vfs_path_new_relative_fails() {
461 let result = FilePath::new("relative/path");
462 assert!(result.is_err());
463 assert!(result.unwrap_err().is_invalid_path());
464 }
465
466 #[test]
467 fn test_vfs_path_new_parent_dir_fails() {
468 let result = FilePath::new("/parent/../escape");
469 assert!(result.is_err());
470 assert!(result.unwrap_err().is_invalid_path());
471 }
472
473 #[test]
474 fn test_vfs_path_new_empty_fails() {
475 let result = FilePath::new("");
476 assert!(result.is_err());
477 }
478
479 #[test]
480 fn test_vfs_path_parent() {
481 let path = FilePath::new("/mcp-tools/servers/test.ts").unwrap();
482 let parent = path.parent().unwrap();
483 assert_eq!(parent.as_str(), "/mcp-tools/servers");
484 }
485
486 #[test]
487 fn test_vfs_path_parent_root() {
488 let path = FilePath::new("/test").unwrap();
489 let parent = path.parent();
490 assert!(parent.is_some());
491 }
492
493 #[test]
494 fn test_vfs_path_is_dir_path() {
495 let dir = FilePath::new("/mcp-tools/servers").unwrap();
496 assert!(dir.is_dir_path());
497
498 let file = FilePath::new("/mcp-tools/test.ts").unwrap();
499 assert!(!file.is_dir_path());
500 }
501
502 #[test]
503 fn test_vfs_path_display() {
504 let path = FilePath::new("/test.ts").unwrap();
505 assert_eq!(format!("{path}"), "/test.ts");
506 }
507
508 #[test]
509 fn test_vfs_file_new() {
510 let file = FileEntry::new("test content");
511 assert_eq!(file.content(), "test content");
512 assert_eq!(file.size(), 12);
513 }
514
515 #[test]
516 fn test_vfs_file_empty() {
517 let file = FileEntry::new("");
518 assert_eq!(file.content(), "");
519 assert_eq!(file.size(), 0);
520 }
521
522 #[test]
523 fn test_vfs_error_is_not_found() {
524 let error = FilesError::FileNotFound {
525 path: "/test".to_string(),
526 };
527 assert!(error.is_not_found());
528 assert!(!error.is_not_directory());
529 assert!(!error.is_invalid_path());
530 }
531
532 #[test]
533 fn test_vfs_error_is_not_directory() {
534 let error = FilesError::NotADirectory {
535 path: "/file.txt".to_string(),
536 };
537 assert!(!error.is_not_found());
538 assert!(error.is_not_directory());
539 assert!(!error.is_invalid_path());
540 }
541
542 #[test]
543 fn test_vfs_error_is_invalid_path() {
544 let error = FilesError::InvalidPath {
545 path: String::new(),
546 };
547 assert!(error.is_invalid_path());
548
549 let error = FilesError::PathNotAbsolute {
550 path: "relative".to_string(),
551 };
552 assert!(error.is_invalid_path());
553
554 let error = FilesError::InvalidPathComponent {
555 path: "../escape".to_string(),
556 };
557 assert!(error.is_invalid_path());
558 }
559
560 #[test]
561 fn test_vfs_path_as_ref() {
562 let vfs_path = FilePath::new("/test.ts").unwrap();
563 let path: &Path = vfs_path.as_ref();
564 assert_eq!(path.to_str(), Some("/test.ts"));
565 }
566}