use std::fmt;
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum FilesError {
#[error("File not found: {path}")]
FileNotFound {
path: String,
},
#[error("Not a directory: {path}")]
NotADirectory {
path: String,
},
#[error("Invalid path: {path}")]
InvalidPath {
path: String,
},
#[error("Path must be absolute: {path}")]
PathNotAbsolute {
path: String,
},
#[error("Path contains invalid components: {path}")]
InvalidPathComponent {
path: String,
},
#[error("I/O error at {path}: {source}")]
IoError {
path: String,
source: std::io::Error,
},
}
impl FilesError {
#[must_use]
pub const fn is_not_found(&self) -> bool {
matches!(self, Self::FileNotFound { .. })
}
#[must_use]
pub const fn is_not_directory(&self) -> bool {
matches!(self, Self::NotADirectory { .. })
}
#[must_use]
pub const fn is_invalid_path(&self) -> bool {
matches!(
self,
Self::InvalidPath { .. }
| Self::PathNotAbsolute { .. }
| Self::InvalidPathComponent { .. }
)
}
#[must_use]
pub const fn is_io_error(&self) -> bool {
matches!(self, Self::IoError { .. })
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FilePath(String);
impl FilePath {
pub fn new(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let path_str = path.to_str().ok_or_else(|| FilesError::InvalidPath {
path: path.display().to_string(),
})?;
let normalized_str = if cfg!(target_os = "windows") {
path_str.replace(std::path::MAIN_SEPARATOR, "/")
} else {
path_str.to_string()
};
if normalized_str.is_empty() {
return Err(FilesError::InvalidPath {
path: String::new(),
});
}
if !normalized_str.starts_with('/') {
return Err(FilesError::PathNotAbsolute {
path: normalized_str,
});
}
if normalized_str.contains("..") {
return Err(FilesError::InvalidPathComponent {
path: normalized_str,
});
}
Ok(Self(normalized_str))
}
#[must_use]
pub fn as_path(&self) -> &Path {
Path::new(&self.0)
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn parent(&self) -> Option<Self> {
self.0.rfind('/').map(|pos| {
if pos == 0 {
Self("/".to_string())
} else {
Self(self.0[..pos].to_string())
}
})
}
#[must_use]
pub fn is_dir_path(&self) -> bool {
self.0
.rfind('/')
.is_some_and(|last_slash| !self.0[last_slash..].contains('.'))
}
}
impl fmt::Display for FilePath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.as_str())
}
}
impl AsRef<Path> for FilePath {
fn as_ref(&self) -> &Path {
Path::new(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
content: String,
}
impl FileEntry {
#[must_use]
pub fn new(content: impl Into<String>) -> Self {
Self {
content: content.into(),
}
}
#[must_use]
pub fn content(&self) -> &str {
&self.content
}
#[must_use]
pub const fn size(&self) -> usize {
self.content.len()
}
}
pub type Result<T> = std::result::Result<T, FilesError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vfs_path_new_valid() {
let path = FilePath::new("/mcp-tools/test.ts").unwrap();
assert_eq!(path.as_str(), "/mcp-tools/test.ts");
}
#[test]
fn test_vfs_path_new_relative_fails() {
let result = FilePath::new("relative/path");
assert!(result.is_err());
assert!(result.unwrap_err().is_invalid_path());
}
#[test]
fn test_vfs_path_new_parent_dir_fails() {
let result = FilePath::new("/parent/../escape");
assert!(result.is_err());
assert!(result.unwrap_err().is_invalid_path());
}
#[test]
fn test_vfs_path_new_empty_fails() {
let result = FilePath::new("");
assert!(result.is_err());
}
#[test]
fn test_vfs_path_parent() {
let path = FilePath::new("/mcp-tools/servers/test.ts").unwrap();
let parent = path.parent().unwrap();
assert_eq!(parent.as_str(), "/mcp-tools/servers");
}
#[test]
fn test_vfs_path_parent_root() {
let path = FilePath::new("/test").unwrap();
let parent = path.parent();
assert!(parent.is_some());
}
#[test]
fn test_vfs_path_is_dir_path() {
let dir = FilePath::new("/mcp-tools/servers").unwrap();
assert!(dir.is_dir_path());
let file = FilePath::new("/mcp-tools/test.ts").unwrap();
assert!(!file.is_dir_path());
}
#[test]
fn test_vfs_path_display() {
let path = FilePath::new("/test.ts").unwrap();
assert_eq!(format!("{path}"), "/test.ts");
}
#[test]
fn test_vfs_file_new() {
let file = FileEntry::new("test content");
assert_eq!(file.content(), "test content");
assert_eq!(file.size(), 12);
}
#[test]
fn test_vfs_file_empty() {
let file = FileEntry::new("");
assert_eq!(file.content(), "");
assert_eq!(file.size(), 0);
}
#[test]
fn test_vfs_error_is_not_found() {
let error = FilesError::FileNotFound {
path: "/test".to_string(),
};
assert!(error.is_not_found());
assert!(!error.is_not_directory());
assert!(!error.is_invalid_path());
}
#[test]
fn test_vfs_error_is_not_directory() {
let error = FilesError::NotADirectory {
path: "/file.txt".to_string(),
};
assert!(!error.is_not_found());
assert!(error.is_not_directory());
assert!(!error.is_invalid_path());
}
#[test]
fn test_vfs_error_is_invalid_path() {
let error = FilesError::InvalidPath {
path: String::new(),
};
assert!(error.is_invalid_path());
let error = FilesError::PathNotAbsolute {
path: "relative".to_string(),
};
assert!(error.is_invalid_path());
let error = FilesError::InvalidPathComponent {
path: "../escape".to_string(),
};
assert!(error.is_invalid_path());
}
#[test]
fn test_vfs_path_as_ref() {
let vfs_path = FilePath::new("/test.ts").unwrap();
let path: &Path = vfs_path.as_ref();
assert_eq!(path.to_str(), Some("/test.ts"));
}
}