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}