From b14c1f32cd5518d6a4aa1d71ea88ea05539d6cb3 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 19:21:19 -0300 Subject: [PATCH 1/6] refactor: fs-service into more organized and managabke modules --- src/fs_service.rs | 1600 +----------------------------- src/fs_service/archive.rs | 2 + src/fs_service/archive/unzip.rs | 63 ++ src/fs_service/archive/zip.rs | 177 ++++ src/fs_service/core.rs | 140 +++ src/fs_service/file_info.rs | 38 - src/fs_service/io.rs | 5 + src/fs_service/io/edit.rs | 202 ++++ src/fs_service/io/read.rs | 297 ++++++ src/fs_service/io/write.rs | 26 + src/fs_service/search.rs | 5 + src/fs_service/search/content.rs | 197 ++++ src/fs_service/search/files.rs | 248 +++++ src/fs_service/search/tree.rs | 220 ++++ src/fs_service/utils.rs | 127 ++- tests/common/common.rs | 2 +- tests/test_fs_service.rs | 2 +- 17 files changed, 1717 insertions(+), 1634 deletions(-) create mode 100644 src/fs_service/archive.rs create mode 100644 src/fs_service/archive/unzip.rs create mode 100644 src/fs_service/archive/zip.rs create mode 100644 src/fs_service/core.rs delete mode 100644 src/fs_service/file_info.rs create mode 100644 src/fs_service/io.rs create mode 100644 src/fs_service/io/edit.rs create mode 100644 src/fs_service/io/read.rs create mode 100644 src/fs_service/io/write.rs create mode 100644 src/fs_service/search.rs create mode 100644 src/fs_service/search/content.rs create mode 100644 src/fs_service/search/files.rs create mode 100644 src/fs_service/search/tree.rs diff --git a/src/fs_service.rs b/src/fs_service.rs index 8797ae7..89ba6e1 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -1,1595 +1,9 @@ -pub mod file_info; +mod archive; +mod core; +mod io; +mod search; pub mod utils; -use crate::{ - error::{ServiceError, ServiceResult}, - fs_service::utils::is_system_metadata_file, - tools::EditOperation, -}; -use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; -use base64::{engine::general_purpose, write::EncoderWriter}; -use file_info::FileInfo; -use futures::{StreamExt, stream}; -use glob_match::glob_match; -use grep::{ - matcher::{Match, Matcher}, - regex::RegexMatcherBuilder, - searcher::{BinaryDetection, Searcher, sinks::UTF8}, -}; -use rayon::iter::{IntoParallelIterator, ParallelBridge, ParallelIterator}; -use rust_mcp_sdk::schema::RpcError; -use serde_json::{Value, json}; -use sha2::{Digest, Sha256}; -use similar::TextDiff; -use std::{ - collections::{HashMap, HashSet}, - env, - fs::{self}, - io::{SeekFrom, Write}, - path::{Path, PathBuf}, - sync::Arc, -}; -use tokio::{ - fs::{File, metadata}, - io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, AsyncWriteExt, BufReader}, - sync::RwLock, -}; -use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; -use utils::{ - contains_symlink, expand_home, format_bytes, normalize_line_endings, normalize_path, - write_zip_entry, -}; -use walkdir::WalkDir; -const SNIPPET_MAX_LENGTH: usize = 200; -const SNIPPET_BACKWARD_CHARS: usize = 30; -const MAX_CONCURRENT_FILE_READ: usize = 5; - -#[cfg(windows)] -pub const OS_LINE_ENDING: &str = "\r\n"; -#[cfg(not(windows))] -pub const OS_LINE_ENDING: &str = "\n"; - -type PathResultList = Vec>; - -pub struct FileSystemService { - allowed_path: RwLock>>, -} - -/// Represents a single match found in a file's content. -#[derive(Debug, Clone)] -pub struct ContentMatchResult { - /// The line number where the match occurred (1-based). - pub line_number: u64, - pub start_pos: usize, - /// The line of text containing the match. - /// If the line exceeds 255 characters (excluding the search term), only a truncated portion will be shown. - pub line_text: String, -} - -/// Represents all matches found in a specific file. -#[derive(Debug, Clone)] -pub struct FileSearchResult { - /// The path to the file where matches were found. - pub file_path: PathBuf, - /// All individual match results within the file. - pub matches: Vec, -} - -/// This addresses the issue with the DockerHub mcp-registry & mcp-gateway where tool discovery fails to resolve -/// references to 'example' or 'default' values when running the run->command from the server.yaml file -/// should be removed once mcp-gateway is more mature -/// reference: https://github.com/docker/mcp-registry/blob/7d815fac2f3b7a9717eebc3f3db215de3ce3c3c7/internal/mcp/client.go#L170-L173 -#[allow(clippy::ptr_arg)] -fn fix_dockerhub_mcp_registry_gateway(input: &String) -> &str { - if input.contains("{{rust-mcp-filesystem.allowed_directories|volume-target|into}}") { - "." - } else { - input - } -} - -impl FileSystemService { - pub fn try_new(allowed_directories: &[String]) -> ServiceResult { - let normalized_dirs: Vec = allowed_directories - .iter() - .map(fix_dockerhub_mcp_registry_gateway) - .map_while(|dir| { - let expand_result = expand_home(dir.into()); - if !expand_result.is_dir() { - panic!("{}", format!("Error: {dir} is not a directory")); - } - Some(expand_result) - }) - .collect(); - - Ok(Self { - allowed_path: RwLock::new(Arc::new(normalized_dirs)), - }) - } - - pub async fn allowed_directories(&self) -> Arc> { - let guard = self.allowed_path.read().await; - guard.clone() - } -} - -impl FileSystemService { - pub fn valid_roots(&self, roots: Vec<&str>) -> ServiceResult<(Vec, Option)> { - let paths: Vec> = roots - .iter() - .map(|p| self.parse_file_path(p)) - .collect::>(); - - // Partition into Ok and Err results - let (ok_paths, err_paths): (PathResultList, PathResultList) = - paths.into_iter().partition(|p| p.is_ok()); - - // using HashSet to remove duplicates - let (valid_roots, no_dir_roots): (HashSet, HashSet) = ok_paths - .into_iter() - .collect::, _>>()? - .into_iter() - .map(expand_home) - .partition(|path| path.is_dir()); - - let skipped_roots = if !err_paths.is_empty() || !no_dir_roots.is_empty() { - Some(format!( - "Warning: skipped {} invalid roots.", - err_paths.len() + no_dir_roots.len() - )) - } else { - None - }; - - let valid_roots = valid_roots.into_iter().collect(); - - Ok((valid_roots, skipped_roots)) - } - - pub async fn update_allowed_paths(&self, valid_roots: Vec) { - let mut guard = self.allowed_path.write().await; - *guard = Arc::new(valid_roots) - } - - /// Converts a string to a `PathBuf`, supporting both raw paths and `file://` URIs. - fn parse_file_path(&self, input: &str) -> ServiceResult { - Ok(PathBuf::from( - input.strip_prefix("file://").unwrap_or(input).trim(), - )) - } - - pub fn validate_path( - &self, - requested_path: &Path, - allowed_directories: Arc>, - ) -> ServiceResult { - if allowed_directories.is_empty() { - return Err(ServiceError::FromString( - "Allowed directories list is empty. Client did not provide any valid root directories.".to_string() - )); - } - - // Expand ~ to home directory - let expanded_path = expand_home(requested_path.to_path_buf()); - - // Resolve the absolute path - let absolute_path = if expanded_path.as_path().is_absolute() { - expanded_path.clone() - } else { - env::current_dir().unwrap().join(&expanded_path) - }; - - // Normalize the path - let normalized_requested = normalize_path(&absolute_path); - - // Check if path is within allowed directories - if !allowed_directories.iter().any(|dir| { - // Must account for both scenarios β€” the requested path may not exist yet, making canonicalization impossible. - normalized_requested.starts_with(dir) - || normalized_requested.starts_with(normalize_path(dir)) - }) { - let symlink_target = if contains_symlink(&absolute_path)? { - "a symlink target path" - } else { - "path" - }; - return Err(ServiceError::FromString(format!( - "Access denied - {} is outside allowed directories: {} not in {}", - symlink_target, - absolute_path.display(), - allowed_directories - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(",\n"), - ))); - } - - Ok(absolute_path) - } - - // Get file stats - pub async fn get_file_stats(&self, file_path: &Path) -> ServiceResult { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - - let metadata = fs::metadata(valid_path)?; - - let size = metadata.len(); - let created = metadata.created().ok(); - let modified = metadata.modified().ok(); - let accessed = metadata.accessed().ok(); - let is_directory = metadata.is_dir(); - let is_file = metadata.is_file(); - - Ok(FileInfo { - size, - created, - modified, - accessed, - is_directory, - is_file, - metadata, - }) - } - - fn detect_line_ending(&self, text: &str) -> &str { - if text.contains("\r\n") { - "\r\n" - } else if text.contains('\r') { - "\r" - } else { - "\n" - } - } - - pub async fn zip_directory( - &self, - input_dir: String, - pattern: String, - target_zip_file: String, - ) -> ServiceResult { - let allowed_directories = self.allowed_directories().await; - let valid_dir_path = - self.validate_path(Path::new(&input_dir), allowed_directories.clone())?; - - let input_dir_str = &valid_dir_path - .as_os_str() - .to_str() - .ok_or(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Invalid UTF-8 in file name", - ))?; - - let target_path = - self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; - - if target_path.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("'{target_zip_file}' already exists!"), - ) - .into()); - } - - let updated_pattern = if pattern.contains('*') { - pattern.to_lowercase() - } else { - format!("*{}*", &pattern.to_lowercase()) - }; - - let glob_pattern = &updated_pattern; - - let entries: Vec<_> = WalkDir::new(&valid_dir_path) - .follow_links(true) - .into_iter() - .filter_map(|entry| entry.ok()) - .filter_map(|entry| { - let full_path = entry.path(); - - self.validate_path(full_path, allowed_directories.clone()) - .ok() - .and_then(|path| { - if path != valid_dir_path - && glob_match(glob_pattern, path.display().to_string().as_ref()) - { - Some(path) - } else { - None - } - }) - }) - .collect(); - - let zip_file = File::create(&target_path).await?; - let mut zip_writer = ZipFileWriter::new(zip_file.compat()); - - for entry_path_buf in &entries { - if entry_path_buf.is_dir() { - continue; - } - let entry_path = entry_path_buf.as_path(); - let entry_str = entry_path.as_os_str().to_str().ok_or(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Invalid UTF-8 in file name", - ))?; - - if !entry_str.starts_with(input_dir_str) { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Entry file path does not start with base input directory path.", - ) - .into()); - } - - let entry_str = &entry_str[input_dir_str.len() + 1..]; - write_zip_entry(entry_str, entry_path, &mut zip_writer).await?; - } - - let z_file = zip_writer.close().await?; - let zip_file_size = if let Ok(meta_data) = z_file.into_inner().metadata().await { - format_bytes(meta_data.len()) - } else { - "unknown".to_string() - }; - let result_message = format!( - "Successfully compressed '{}' directory into '{}' ({}).", - input_dir, - target_path.display(), - zip_file_size - ); - Ok(result_message) - } - - pub async fn zip_files( - &self, - input_files: Vec, - target_zip_file: String, - ) -> ServiceResult { - let file_count = input_files.len(); - - if file_count == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "No file(s) to zip. The input files array is empty.", - ) - .into()); - } - let allowed_directories = self.allowed_directories().await; - let target_path = - self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; - - if target_path.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("'{target_zip_file}' already exists!"), - ) - .into()); - } - - let source_paths = input_files - .iter() - .map(|p| self.validate_path(Path::new(p), allowed_directories.clone())) - .collect::, _>>()?; - - let zip_file = File::create(&target_path).await?; - let mut zip_writer = ZipFileWriter::new(zip_file.compat()); - for path in source_paths { - let filename = path.file_name().ok_or(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Invalid path!", - ))?; - - let filename = filename.to_str().ok_or(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "Invalid UTF-8 in file name", - ))?; - - write_zip_entry(filename, &path, &mut zip_writer).await?; - } - let z_file = zip_writer.close().await?; - - let zip_file_size = if let Ok(meta_data) = z_file.into_inner().metadata().await { - format_bytes(meta_data.len()) - } else { - "unknown".to_string() - }; - - let result_message = format!( - "Successfully compressed {} {} into '{}' ({}).", - file_count, - if file_count == 1 { "file" } else { "files" }, - target_path.display(), - zip_file_size - ); - Ok(result_message) - } - - pub async fn unzip_file(&self, zip_file: &str, target_dir: &str) -> ServiceResult { - let allowed_directories = self.allowed_directories().await; - - let zip_file = self.validate_path(Path::new(&zip_file), allowed_directories.clone())?; - let target_dir_path = self.validate_path(Path::new(target_dir), allowed_directories)?; - if !zip_file.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - "Zip file does not exists.", - ) - .into()); - } - - if target_dir_path.exists() { - return Err(std::io::Error::new( - std::io::ErrorKind::AlreadyExists, - format!("'{target_dir}' directory already exists!"), - ) - .into()); - } - - let file = BufReader::new(File::open(zip_file).await?); - let mut zip = ZipFileReader::with_tokio(file).await?; - - let file_count = zip.file().entries().len(); - - for index in 0..file_count { - let entry = zip.file().entries().get(index).unwrap(); - let entry_path = target_dir_path.join(entry.filename().as_str()?); - // Ensure the parent directory exists - if let Some(parent) = entry_path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - - // Extract the file - let reader = zip.reader_without_entry(index).await?; - let mut compat_reader = reader.compat(); - let mut output_file = File::create(&entry_path).await?; - - tokio::io::copy(&mut compat_reader, &mut output_file).await?; - output_file.flush().await?; - } - - let result_message = format!( - "Successfully extracted {} {} into '{}'.", - file_count, - if file_count == 1 { "file" } else { "files" }, - target_dir_path.display() - ); - - Ok(result_message) - } - - pub fn mime_from_path(&self, path: &Path) -> ServiceResult { - let is_svg = path - .extension() - .is_some_and(|e| e.to_str().is_some_and(|s| s == "svg")); - // consider it is a svg file as we cannot detect svg from bytes pattern - if is_svg { - return Ok(infer::Type::new( - infer::MatcherType::Image, - "image/svg+xml", - "svg", - |_: &[u8]| true, - )); - - // infer::Type::new(infer::MatcherType::Image, "", "svg",); - } - let kind = infer::get_from_path(path)?.ok_or(ServiceError::FromString( - "File tyle is unknown!".to_string(), - ))?; - Ok(kind) - } - - pub fn filesize_in_range( - &self, - file_size: u64, - min_bytes: Option, - max_bytes: Option, - ) -> bool { - if min_bytes.is_none() && max_bytes.is_none() { - return true; - } - match (min_bytes, max_bytes) { - (_, Some(max)) if file_size > max => false, - (Some(min), _) if file_size < min => false, - _ => true, - } - } - - pub async fn validate_file_size>( - &self, - path: P, - min_bytes: Option, - max_bytes: Option, - ) -> ServiceResult<()> { - if min_bytes.is_none() && max_bytes.is_none() { - return Ok(()); - } - - let file_size = metadata(&path).await?.len() as usize; - - match (min_bytes, max_bytes) { - (_, Some(max)) if file_size > max => Err(ServiceError::FileTooLarge(max)), - (Some(min), _) if file_size < min => Err(ServiceError::FileTooSmall(min)), - _ => Ok(()), - } - } - - pub async fn read_media_files( - &self, - paths: Vec, - max_bytes: Option, - ) -> ServiceResult> { - let results = stream::iter(paths) - .map(|path| async { - self.read_media_file(Path::new(&path), max_bytes) - .await - .map_err(|e| (path, e)) - }) - .buffer_unordered(MAX_CONCURRENT_FILE_READ) // Process up to MAX_CONCURRENT_FILE_READ files concurrently - .filter_map(|result| async move { result.ok() }) - .collect::>() - .await; - Ok(results) - } - - pub async fn read_media_file( - &self, - file_path: &Path, - max_bytes: Option, - ) -> ServiceResult<(infer::Type, String)> { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - self.validate_file_size(&valid_path, None, max_bytes) - .await?; - let kind = self.mime_from_path(&valid_path)?; - let content = self.read_file_as_base64(&valid_path).await?; - Ok((kind, content)) - } - - // reads file as base64 efficiently in a streaming manner - async fn read_file_as_base64(&self, file_path: &Path) -> ServiceResult { - let file = File::open(file_path).await?; - let mut reader = BufReader::new(file); - - let mut output = Vec::new(); - { - // Wrap output Vec in a Base64 encoder writer - let mut encoder = EncoderWriter::new(&mut output, &general_purpose::STANDARD); - - let mut buffer = [0u8; 8192]; - loop { - let n = reader.read(&mut buffer).await?; - if n == 0 { - break; - } - // Write raw bytes to the Base64 encoder - encoder.write_all(&buffer[..n])?; - } - // Make sure to flush any remaining bytes - encoder.flush()?; - } // drop encoder before consuming output - - // Convert the Base64 bytes to String (safe UTF-8) - let base64_string = - String::from_utf8(output).map_err(|err| ServiceError::FromString(format!("{err}")))?; - Ok(base64_string) - } - - pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - let content = tokio::fs::read_to_string(valid_path).await?; - Ok(content) - } - - pub async fn create_directory(&self, file_path: &Path) -> ServiceResult<()> { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - tokio::fs::create_dir_all(valid_path).await?; - Ok(()) - } - - pub async fn move_file(&self, src_path: &Path, dest_path: &Path) -> ServiceResult<()> { - let allowed_directories = self.allowed_directories().await; - let valid_src_path = self.validate_path(src_path, allowed_directories.clone())?; - let valid_dest_path = self.validate_path(dest_path, allowed_directories)?; - tokio::fs::rename(valid_src_path, valid_dest_path).await?; - Ok(()) - } - - pub async fn list_directory(&self, dir_path: &Path) -> ServiceResult> { - let allowed_directories = self.allowed_directories().await; - - let valid_path = self.validate_path(dir_path, allowed_directories)?; - - let mut dir = tokio::fs::read_dir(valid_path).await?; - - let mut entries = Vec::new(); - - // Use a loop to collect the directory entries - while let Some(entry) = dir.next_entry().await? { - entries.push(entry); - } - - Ok(entries) - } - - pub async fn write_file(&self, file_path: &Path, content: &String) -> ServiceResult<()> { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - tokio::fs::write(valid_path, content).await?; - Ok(()) - } - - /// Searches for files in the directory tree starting at `root_path` that match the given `pattern`, - /// excluding paths that match any of the `exclude_patterns`. - /// - /// # Arguments - /// * `root_path` - The root directory to start the search from. - /// * `pattern` - A glob pattern to match file names (case-insensitive). If no wildcards are provided, - /// the pattern is wrapped in '*' for partial matching. - /// * `exclude_patterns` - A list of glob patterns to exclude paths (case-sensitive). - /// - /// # Returns - /// A `ServiceResult` containing a vector of`walkdir::DirEntry` objects for matching files, - /// or a `ServiceError` if an error occurs. - pub async fn search_files( - &self, - root_path: &Path, - pattern: String, - exclude_patterns: Vec, - min_bytes: Option, - max_bytes: Option, - ) -> ServiceResult> { - let result = self - .search_files_iter(root_path, pattern, exclude_patterns, min_bytes, max_bytes) - .await?; - Ok(result.collect::>()) - } - - /// Returns an iterator over files in the directory tree starting at `root_path` that match - /// the given `pattern`, excluding paths that match any of the `exclude_patterns`. - /// - /// # Arguments - /// * `root_path` - The root directory to start the search from. - /// * `pattern` - A glob pattern to match file names. If no wildcards are provided, the pattern is wrapped in `**/*{pattern}*` for partial matching. - /// * `exclude_patterns` - A list of glob patterns to exclude paths (case-sensitive). - /// - /// # Returns - /// A `ServiceResult` containing an iterator yielding `walkdir::DirEntry` objects for matching files, - /// or a `ServiceError` if an error occurs. - pub async fn search_files_iter<'a>( - &'a self, - // root_path: impl Into, - root_path: &'a Path, - pattern: String, - exclude_patterns: Vec, - min_bytes: Option, - max_bytes: Option, - ) -> ServiceResult + 'a> { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(root_path, allowed_directories.clone())?; - - let updated_pattern = if pattern.contains('*') { - pattern.to_lowercase() - } else { - format!("**/*{}*", &pattern.to_lowercase()) - }; - let glob_pattern = updated_pattern; - - let result = WalkDir::new(valid_path) - .follow_links(true) - .into_iter() - .filter_entry(move |dir_entry| { - let full_path = dir_entry.path(); - - // Validate each path before processing - let validated_path = self - .validate_path(full_path, allowed_directories.clone()) - .ok(); - - if validated_path.is_none() { - // Skip invalid paths during search - return false; - } - - // Get the relative path from the root_path - let relative_path = full_path.strip_prefix(root_path).unwrap_or(full_path); - - let mut should_exclude = exclude_patterns.iter().any(|pattern| { - let glob_pattern = if pattern.contains('*') { - pattern.strip_prefix("/").unwrap_or(pattern).to_owned() - } else { - format!("*{pattern}*") - }; - - glob_match(&glob_pattern, relative_path.to_str().unwrap_or("")) - }); - - // enforce min/max bytes - if !should_exclude && (min_bytes.is_none() || max_bytes.is_none()) { - match dir_entry.metadata().ok() { - Some(metadata) => { - if !self.filesize_in_range(metadata.len(), min_bytes, max_bytes) { - should_exclude = true; - } - } - None => { - should_exclude = true; - } - } - } - - !should_exclude - }) - .filter_map(|v| v.ok()) - .filter(move |entry| { - if root_path == entry.path() { - return false; - } - - glob_match( - &glob_pattern, - &entry.file_name().to_str().unwrap_or("").to_lowercase(), - ) - }); - - Ok(result) - } - - /// Generates a JSON representation of a directory tree starting at the given path. - /// - /// This function recursively builds a JSON array object representing the directory structure, - /// where each entry includes a `name` (file or directory name), `type` ("file" or "directory"), - /// and for directories, a `children` array containing their contents. Files do not have a - /// `children` field. - /// - /// The function supports optional constraints to limit the tree size: - /// - `max_depth`: Limits the depth of directory traversal. - /// - `max_files`: Limits the total number of entries (files and directories). - /// - /// # IMPORTANT NOTE - /// - /// use max_depth or max_files could lead to partial or skewed representations of actual directory tree - pub fn directory_tree>( - &self, - root_path: P, - max_depth: Option, - max_files: Option, - current_count: &mut usize, - allowed_directories: Arc>, - ) -> ServiceResult<(Value, bool)> { - let valid_path = self.validate_path(root_path.as_ref(), allowed_directories.clone())?; - - let metadata = fs::metadata(&valid_path)?; - if !metadata.is_dir() { - return Err(ServiceError::FromString( - "Root path must be a directory".into(), - )); - } - - let mut children = Vec::new(); - let mut reached_max_depth = false; - - if max_depth != Some(0) { - for entry in WalkDir::new(valid_path) - .min_depth(1) - .max_depth(1) - .follow_links(true) - .into_iter() - .filter_map(|e| e.ok()) - { - let child_path = entry.path(); - let metadata = fs::metadata(child_path)?; - - let entry_name = child_path - .file_name() - .ok_or(ServiceError::FromString("Invalid path".to_string()))? - .to_string_lossy() - .into_owned(); - - // Increment the count for this entry - *current_count += 1; - - // Check if we've exceeded max_files (if set) - if let Some(max) = max_files { - if *current_count > max { - continue; // Skip this entry but continue processing others - } - } - - let mut json_entry = json!({ - "name": entry_name, - "type": if metadata.is_dir() { "directory" } else { "file" } - }); - - if metadata.is_dir() { - let next_depth = max_depth.map(|d| d - 1); - let (child_children, child_reached_max_depth) = self.directory_tree( - child_path, - next_depth, - max_files, - current_count, - allowed_directories.clone(), - )?; - json_entry - .as_object_mut() - .unwrap() - .insert("children".to_string(), child_children); - reached_max_depth |= child_reached_max_depth; - } - children.push(json_entry); - } - } else { - // If max_depth is 0, we skip processing this directory's children - reached_max_depth = true; - } - Ok((Value::Array(children), reached_max_depth)) - } - - pub fn create_unified_diff( - &self, - original_content: &str, - new_content: &str, - filepath: Option, - ) -> String { - // Ensure consistent line endings for diff - let normalized_original = normalize_line_endings(original_content); - let normalized_new = normalize_line_endings(new_content); - - // // Generate the diff using TextDiff - let diff = TextDiff::from_lines(&normalized_original, &normalized_new); - - let file_name = filepath.unwrap_or("file".to_string()); - // Format the diff as a unified diff - let patch = diff - .unified_diff() - .header( - format!("{file_name}\toriginal").as_str(), - format!("{file_name}\tmodified").as_str(), - ) - .context_radius(4) - .to_string(); - - format!("Index: {}\n{}\n{}", file_name, "=".repeat(68), patch) - } - - pub async fn apply_file_edits( - &self, - file_path: &Path, - edits: Vec, - dry_run: Option, - save_to: Option<&Path>, - ) -> ServiceResult { - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - - // Read file content and normalize line endings - let content_str = tokio::fs::read_to_string(&valid_path).await?; - let original_line_ending = self.detect_line_ending(&content_str); - let content_str = normalize_line_endings(&content_str); - - // Apply edits sequentially - let mut modified_content = content_str.clone(); - - for edit in edits { - let normalized_old = normalize_line_endings(&edit.old_text); - let normalized_new = normalize_line_endings(&edit.new_text); - // If exact match exists, use it - if modified_content.contains(&normalized_old) { - modified_content = modified_content.replacen(&normalized_old, &normalized_new, 1); - continue; - } - - // Otherwise, try line-by-line matching with flexibility for whitespace - let old_lines: Vec = normalized_old - .trim_end() - .split('\n') - .map(|s| s.to_string()) - .collect(); - - let content_lines: Vec = modified_content - .trim_end() - .split('\n') - .map(|s| s.to_string()) - .collect(); - - let mut match_found = false; - - // skip when the match is impossible: - if old_lines.len() > content_lines.len() { - let error_message = format!( - "Cannot apply edit: the original text spans more lines ({}) than the file content ({}).", - old_lines.len(), - content_lines.len() - ); - - return Err(RpcError::internal_error() - .with_message(error_message) - .into()); - } - - let max_start = content_lines.len().saturating_sub(old_lines.len()); - for i in 0..=max_start { - let potential_match = &content_lines[i..i + old_lines.len()]; - - // Compare lines with normalized whitespace - let is_match = old_lines.iter().enumerate().all(|(j, old_line)| { - let content_line = &potential_match[j]; - old_line.trim() == content_line.trim() - }); - - if is_match { - // Preserve original indentation of first line - let original_indent = content_lines[i] - .chars() - .take_while(|&c| c.is_whitespace()) - .collect::(); - - let new_lines: Vec = normalized_new - .split('\n') - .enumerate() - .map(|(j, line)| { - // Keep indentation of the first line - if j == 0 { - return format!("{}{}", original_indent, line.trim_start()); - } - - // For subsequent lines, preserve relative indentation and original whitespace type - let old_indent = old_lines - .get(j) - .map(|line| { - line.chars() - .take_while(|&c| c.is_whitespace()) - .collect::() - }) - .unwrap_or_default(); - - let new_indent = line - .chars() - .take_while(|&c| c.is_whitespace()) - .collect::(); - - // Use the same whitespace character as original_indent (tabs or spaces) - let indent_char = if original_indent.contains('\t') { - "\t" - } else { - " " - }; - let relative_indent = if new_indent.len() >= old_indent.len() { - new_indent.len() - old_indent.len() - } else { - 0 // Don't reduce indentation below original - }; - format!( - "{}{}{}", - &original_indent, - &indent_char.repeat(relative_indent), - line.trim_start() - ) - }) - .collect(); - - let mut content_lines = content_lines.clone(); - content_lines.splice(i..i + old_lines.len(), new_lines); - modified_content = content_lines.join("\n"); - match_found = true; - break; - } - } - if !match_found { - return Err(RpcError::internal_error() - .with_message(format!( - "Could not find exact match for edit:\n{}", - edit.old_text - )) - .into()); - } - } - - let diff = self.create_unified_diff( - &content_str, - &modified_content, - Some(valid_path.display().to_string()), - ); - - // Format diff with appropriate number of backticks - let mut num_backticks = 3; - while diff.contains(&"`".repeat(num_backticks)) { - num_backticks += 1; - } - let formatted_diff = format!( - "{}diff\n{}{}\n\n", - "`".repeat(num_backticks), - diff, - "`".repeat(num_backticks) - ); - - let is_dry_run = dry_run.unwrap_or(false); - - if !is_dry_run { - let target = save_to.unwrap_or(valid_path.as_path()); - let modified_content = modified_content.replace("\n", original_line_ending); - tokio::fs::write(target, modified_content).await?; - } - - Ok(formatted_diff) - } - - pub fn escape_regex(&self, text: &str) -> String { - // Covers special characters in regex engines (RE2, PCRE, JS, Python) - const SPECIAL_CHARS: &[char] = &[ - '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '\\', '|', '/', - ]; - - let mut escaped = String::with_capacity(text.len()); - - for ch in text.chars() { - if SPECIAL_CHARS.contains(&ch) { - escaped.push('\\'); - } - escaped.push(ch); - } - - escaped - } - - // Searches the content of a file for occurrences of the given query string. - /// - /// This method searches the file specified by `file_path` for lines matching the `query`. - /// The search can be performed as a regular expression or as a literal string, - /// depending on the `is_regex` flag. - /// - /// If matched line is larger than 255 characters, a snippet will be extracted around the matched text. - /// - pub fn content_search( - &self, - query: &str, - file_path: impl AsRef, - is_regex: Option, - ) -> ServiceResult> { - let query = if is_regex.unwrap_or_default() { - query.to_string() - } else { - self.escape_regex(query) - }; - - let matcher = RegexMatcherBuilder::new() - .case_insensitive(true) - .build(query.as_str())?; - - let mut searcher = Searcher::new(); - let mut result = FileSearchResult { - file_path: file_path.as_ref().to_path_buf(), - matches: vec![], - }; - - searcher.set_binary_detection(BinaryDetection::quit(b'\x00')); - - searcher.search_path( - &matcher, - file_path, - UTF8(|line_number, line| { - let actual_match = matcher.find(line.as_bytes())?.unwrap(); - - result.matches.push(ContentMatchResult { - line_number, - start_pos: actual_match.start(), - line_text: self.extract_snippet(line, actual_match, None, None), - }); - Ok(true) - }), - )?; - - if result.matches.is_empty() { - return Ok(None); - } - - Ok(Some(result)) - } - - /// Extracts a snippet from a given line of text around a match. - /// - /// It extracts a substring starting a fixed number of characters (`SNIPPET_BACKWARD_CHARS`) - /// before the start position of the `match`, and extends up to `max_length` characters - /// If the snippet does not include the beginning or end of the original line, ellipses (`"..."`) are added - /// to indicate the truncation. - pub fn extract_snippet( - &self, - line: &str, - match_result: Match, - max_length: Option, - backward_chars: Option, - ) -> String { - let max_length = max_length.unwrap_or(SNIPPET_MAX_LENGTH); - let backward_chars = backward_chars.unwrap_or(SNIPPET_BACKWARD_CHARS); - - // Calculate the number of leading whitespace bytes to adjust for trimmed input - let start_pos = line.len() - line.trim_start().len(); - // Trim leading and trailing whitespace from the input line - let line = line.trim(); - - // Calculate the desired start byte index by adjusting match start for trimming and backward chars - // match_result.start() is the byte index in the original string - // Subtract start_pos to account for trimmed whitespace and backward_chars to include context before the match - let desired_start = (match_result.start() - start_pos).saturating_sub(backward_chars); - - // Find the nearest valid UTF-8 character boundary at or after desired_start - // Prevents "byte index is not a char boundary" panic by ensuring the slice starts at a valid character (issue #37) - let snippet_start = line - .char_indices() - .map(|(i, _)| i) - .find(|&i| i >= desired_start) - .unwrap_or(desired_start.min(line.len())); - // Initialize a counter for tracking characters to respect max_length - let mut char_count = 0; - - // Calculate the desired end byte index by counting max_length characters from snippet_start - // Take max_length + 1 to find the boundary after the last desired character - let desired_end = line[snippet_start..] - .char_indices() - .take(max_length + 1) - .find(|&(_, _)| { - char_count += 1; - char_count > max_length - }) - .map(|(i, _)| snippet_start + i) - .unwrap_or(line.len()); - - // Ensure snippet_end is a valid UTF-8 character boundary at or after desired_end - // This prevents slicing issues with multi-byte characters - let snippet_end = line - .char_indices() - .map(|(i, _)| i) - .find(|&i| i >= desired_end) - .unwrap_or(line.len()); - - // Cap snippet_end to avoid exceeding the string length - let snippet_end = snippet_end.min(line.len()); - - // Extract the snippet from the trimmed line using the calculated byte indices - let snippet = &line[snippet_start..snippet_end]; - - let mut result = String::new(); - // Add leading ellipsis if the snippet doesn't start at the beginning of the trimmed line - if snippet_start > 0 { - result.push_str("..."); - } - - result.push_str(snippet); - - // Add trailing ellipsis if the snippet doesn't reach the end of the trimmed line - if snippet_end < line.len() { - result.push_str("..."); - } - result - } - - #[allow(clippy::too_many_arguments)] - pub async fn search_files_content( - &self, - root_path: impl AsRef, - pattern: &str, - query: &str, - is_regex: bool, - exclude_patterns: Option>, - min_bytes: Option, - max_bytes: Option, - ) -> ServiceResult> { - let files_iter = self - .search_files_iter( - root_path.as_ref(), - pattern.to_string(), - exclude_patterns.to_owned().unwrap_or_default(), - min_bytes, - max_bytes, - ) - .await?; - - let results: Vec = files_iter - .filter_map(|entry| { - self.content_search(query, entry.path(), Some(is_regex)) - .ok() - .and_then(|v| v) - }) - .collect(); - Ok(results) - } - - /// Reads the first n lines from a text file, preserving line endings. - /// Args: - /// file_path: Path to the file - /// n: Number of lines to read - /// Returns a String containing the first n lines with original line endings or an error if the path is invalid or file cannot be read. - pub async fn head_file(&self, file_path: &Path, n: usize) -> ServiceResult { - // Validate file path against allowed directories - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - - // Open file asynchronously and create a BufReader - let file = File::open(&valid_path).await?; - let mut reader = BufReader::new(file); - let mut result = String::with_capacity(n * 100); // Estimate capacity (avg 100 bytes/line) - let mut count = 0; - - // Read lines asynchronously, preserving line endings - let mut line = Vec::new(); - while count < n { - line.clear(); - let bytes_read = reader.read_until(b'\n', &mut line).await?; - if bytes_read == 0 { - break; // Reached EOF - } - result.push_str(&String::from_utf8_lossy(&line)); - count += 1; - } - - Ok(result) - } - - /// Reads the last n lines from a text file, preserving line endings. - /// Args: - /// file_path: Path to the file - /// n: Number of lines to read - /// Returns a String containing the last n lines with original line endings or an error if the path is invalid or file cannot be read. - pub async fn tail_file(&self, file_path: &Path, n: usize) -> ServiceResult { - // Validate file path against allowed directories - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(file_path, allowed_directories)?; - - // Open file asynchronously - let file = File::open(&valid_path).await?; - let file_size = file.metadata().await?.len(); - - // If file is empty or n is 0, return empty string - if file_size == 0 || n == 0 { - return Ok(String::new()); - } - - // Create a BufReader - let mut reader = BufReader::new(file); - let mut line_count = 0; - let mut pos = file_size; - let chunk_size = 8192; // 8KB chunks - let mut buffer = vec![0u8; chunk_size]; - let mut newline_positions = Vec::new(); - - // Read backwards to collect all newline positions - while pos > 0 { - let read_size = chunk_size.min(pos as usize); - pos -= read_size as u64; - reader.seek(SeekFrom::Start(pos)).await?; - let read_bytes = reader.read_exact(&mut buffer[..read_size]).await?; - - // Process chunk in reverse to find newlines - for (i, byte) in buffer[..read_bytes].iter().enumerate().rev() { - if *byte == b'\n' { - newline_positions.push(pos + i as u64); - line_count += 1; - } - } - } - - // Check if file ends with a non-newline character (partial last line) - if file_size > 0 { - let mut temp_reader = BufReader::new(File::open(&valid_path).await?); - temp_reader.seek(SeekFrom::End(-1)).await?; - let mut last_byte = [0u8; 1]; - temp_reader.read_exact(&mut last_byte).await?; - if last_byte[0] != b'\n' { - line_count += 1; - } - } - - // Determine start position for reading the last n lines - let start_pos = if line_count <= n { - 0 // Read from start if fewer than n lines - } else { - *newline_positions.get(line_count - n).unwrap_or(&0) + 1 - }; - - // Read forward from start_pos - reader.seek(SeekFrom::Start(start_pos)).await?; - let mut result = String::with_capacity(n * 100); // Estimate capacity - let mut line = Vec::new(); - let mut lines_read = 0; - - while lines_read < n { - line.clear(); - let bytes_read = reader.read_until(b'\n', &mut line).await?; - if bytes_read == 0 { - // Handle partial last line at EOF - if !line.is_empty() { - result.push_str(&String::from_utf8_lossy(&line)); - } - break; - } - result.push_str(&String::from_utf8_lossy(&line)); - lines_read += 1; - } - - Ok(result) - } - - /// Reads lines from a text file starting at the specified offset (0-based), preserving line endings. - /// Args: - /// path: Path to the file - /// offset: Number of lines to skip (0-based) - /// limit: Optional maximum number of lines to read - /// Returns a String containing the selected lines with original line endings or an error if the path is invalid or file cannot be read. - pub async fn read_file_lines( - &self, - path: &Path, - offset: usize, - limit: Option, - ) -> ServiceResult { - // Validate file path against allowed directories - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(path, allowed_directories)?; - - // Open file and get metadata before moving into BufReader - let file = File::open(&valid_path).await?; - let file_size = file.metadata().await?.len(); - let mut reader = BufReader::new(file); - - // If file is empty or limit is 0, return empty string - if file_size == 0 || limit == Some(0) { - return Ok(String::new()); - } - - // Skip offset lines (0-based indexing) - let mut buffer = Vec::new(); - for _ in 0..offset { - buffer.clear(); - if reader.read_until(b'\n', &mut buffer).await? == 0 { - return Ok(String::new()); // EOF before offset - } - } - - // Read lines up to limit (or all remaining if limit is None) - let mut result = String::with_capacity(limit.unwrap_or(100) * 100); // Estimate capacity - match limit { - Some(max_lines) => { - for _ in 0..max_lines { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await?; - if bytes_read == 0 { - break; // Reached EOF - } - result.push_str(&String::from_utf8_lossy(&buffer)); - } - } - None => { - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await?; - if bytes_read == 0 { - break; // Reached EOF - } - result.push_str(&String::from_utf8_lossy(&buffer)); - } - } - } - - Ok(result) - } - - /// Calculates the total size (in bytes) of all files within a directory tree. - /// - /// This function recursively searches the specified `root_path` for files, - /// filters out directories and non-file entries, and sums the sizes of all found files. - /// The size calculation is parallelized using Rayon for improved performance on large directories. - /// - /// # Arguments - /// * `root_path` - The root directory path to start the size calculation. - /// - /// # Returns - /// Returns a `ServiceResult` containing the total size in bytes of all files under the `root_path`. - /// - /// # Notes - /// - Only files are included in the size calculation; directories and other non-file entries are ignored. - /// - The search pattern is `"**/*"` (all files) and no exclusions are applied. - /// - Parallel iteration is used to speed up the metadata fetching and summation. - pub async fn calculate_directory_size(&self, root_path: &Path) -> ServiceResult { - let entries = self - .search_files_iter(root_path, "**/*".to_string(), vec![], None, None) - .await? - .filter(|e| e.file_type().is_file()); // Only process files - - // Use rayon to parallelize size summation - let total_size: u64 = entries - .par_bridge() // Convert to parallel iterator - .filter_map(|entry| entry.metadata().ok().map(|meta| meta.len())) - .sum(); - - Ok(total_size) - } - - /// Recursively finds all empty directories within the given root path. - /// - /// A directory is considered empty if it contains no files in itself or any of its subdirectories - /// except OS metadata files: `.DS_Store` (macOS) and `Thumbs.db` (Windows) - /// Empty subdirectories are allowed. You can optionally provide a list of glob-style patterns in - /// `exclude_patterns` to ignore certain paths during the search (e.g., to skip system folders or hidden directories). - /// - /// # Arguments - /// - `root_path`: The starting directory to search. - /// - `exclude_patterns`: Optional list of glob patterns to exclude from the search. - /// Directories matching these patterns will be ignored. - /// - /// # Errors - /// Returns an error if the root path is invalid or inaccessible. - /// - /// # Returns - /// A list of paths to empty directories, as strings, including parent directories that contain only empty subdirectories. - /// Recursively finds all empty directories within the given root path. - /// - /// A directory is considered empty if it contains no files in itself or any of its subdirectories. - /// Empty subdirectories are allowed. You can optionally provide a list of glob-style patterns in - /// `exclude_patterns` to ignore certain paths during the search (e.g., to skip system folders or hidden directories). - /// - /// # Arguments - /// - `root_path`: The starting directory to search. - /// - `exclude_patterns`: Optional list of glob patterns to exclude from the search. - /// Directories matching these patterns will be ignored. - /// - /// # Errors - /// Returns an error if the root path is invalid or inaccessible. - /// - /// # Returns - /// A list of paths to all empty directories, as strings, including parent directories that contain only empty subdirectories. - pub async fn find_empty_directories( - &self, - root_path: &Path, - exclude_patterns: Option>, - ) -> ServiceResult> { - let walker = self - .search_files_iter( - root_path, - "**/*".to_string(), - exclude_patterns.unwrap_or_default(), - None, - None, - ) - .await? - .filter(|e| e.file_type().is_dir()); // Only directories - - let mut empty_dirs = Vec::new(); - - // Check each directory for emptiness - for entry in walker { - let is_empty = WalkDir::new(entry.path()) - .into_iter() - .filter_map(|e| e.ok()) - .all(|e| !e.file_type().is_file() || is_system_metadata_file(e.file_name())); // Directory is empty if no files are found in it or subdirs, ".DS_Store" will be ignores on Mac - - if is_empty { - if let Some(path_str) = entry.path().to_str() { - empty_dirs.push(path_str.to_string()); - } - } - } - - Ok(empty_dirs) - } - - /// Finds groups of duplicate files within the given root path. - /// Returns a vector of vectors, where each inner vector contains paths to files with identical content. - /// Files are considered duplicates if they have the same size and SHA-256 hash. - pub async fn find_duplicate_files( - &self, - root_path: &Path, - pattern: Option, - exclude_patterns: Option>, - min_bytes: Option, - max_bytes: Option, - ) -> ServiceResult>> { - // Validate root path against allowed directories - let allowed_directories = self.allowed_directories().await; - let valid_path = self.validate_path(root_path, allowed_directories)?; - - // Get Tokio runtime handle - let rt = tokio::runtime::Handle::current(); - - // Step 1: Collect files and group by size - let mut size_map: HashMap> = HashMap::new(); - let entries = self - .search_files_iter( - &valid_path, - pattern.unwrap_or("**/*".to_string()), - exclude_patterns.unwrap_or_default(), - min_bytes, - max_bytes, - ) - .await? - .filter(|e| e.file_type().is_file()); // Only files - - for entry in entries { - if let Ok(metadata) = entry.metadata() { - if let Some(path_str) = entry.path().to_str() { - size_map - .entry(metadata.len()) - .or_default() - .push(path_str.to_string()); - } - } - } - - // Filter out sizes with only one file (no duplicates possible) - let size_groups: Vec> = size_map - .into_iter() - .collect::>() // Collect into Vec to enable parallel iteration - .into_par_iter() - .filter(|(_, paths)| paths.len() > 1) - .map(|(_, paths)| paths) - .collect(); - - // Step 2: Group by quick hash (first 4KB) - let mut quick_hash_map: HashMap, Vec> = HashMap::new(); - for paths in size_groups.into_iter() { - let quick_hashes: Vec<(String, Vec)> = paths - .into_par_iter() - .filter_map(|path| { - let rt = rt.clone(); // Clone the runtime handle for this task - rt.block_on(async { - let file = File::open(&path).await.ok()?; - let mut reader = tokio::io::BufReader::new(file); - let mut buffer = vec![0u8; 4096]; // Read first 4KB - let bytes_read = reader.read(&mut buffer).await.ok()?; - let mut hasher = Sha256::new(); - hasher.update(&buffer[..bytes_read]); - Some((path, hasher.finalize().to_vec())) - }) - }) - .collect(); - - for (path, hash) in quick_hashes { - quick_hash_map.entry(hash).or_default().push(path); - } - } - - // Step 3: Group by full hash for groups with multiple files - let mut full_hash_map: HashMap, Vec> = HashMap::new(); - let filtered_quick_hashes: Vec<(Vec, Vec)> = quick_hash_map - .into_iter() - .collect::>() - .into_par_iter() - .filter(|(_, paths)| paths.len() > 1) - .collect(); - - for (_quick_hash, paths) in filtered_quick_hashes { - let full_hashes: Vec<(String, Vec)> = paths - .into_par_iter() - .filter_map(|path| { - let rt = rt.clone(); // Clone the runtime handle for this task - rt.block_on(async { - let file = File::open(&path).await.ok()?; - let mut reader = tokio::io::BufReader::new(file); - let mut hasher = Sha256::new(); - let mut buffer = vec![0u8; 8192]; // 8KB chunks - loop { - let bytes_read = reader.read(&mut buffer).await.ok()?; - if bytes_read == 0 { - break; - } - hasher.update(&buffer[..bytes_read]); - } - Some((path, hasher.finalize().to_vec())) - }) - }) - .collect(); - - for (path, hash) in full_hashes { - full_hash_map.entry(hash).or_default().push(path); - } - } - - // Collect groups of duplicates (only groups with more than one file) - let duplicates: Vec> = full_hash_map - .into_values() - .filter(|group| group.len() > 1) - .collect(); - - Ok(duplicates) - } -} +pub use core::FileSystemService; +pub use io::FileInfo; +pub use search::FileSearchResult; diff --git a/src/fs_service/archive.rs b/src/fs_service/archive.rs new file mode 100644 index 0000000..47ec5e6 --- /dev/null +++ b/src/fs_service/archive.rs @@ -0,0 +1,2 @@ +pub mod unzip; +pub mod zip; diff --git a/src/fs_service/archive/unzip.rs b/src/fs_service/archive/unzip.rs new file mode 100644 index 0000000..4cbf187 --- /dev/null +++ b/src/fs_service/archive/unzip.rs @@ -0,0 +1,63 @@ +use crate::{error::ServiceResult, fs_service::FileSystemService}; +use async_zip::tokio::read::seek::ZipFileReader; +use std::path::Path; +use tokio::{ + fs::File, + io::{AsyncWriteExt, BufReader}, +}; +use tokio_util::compat::FuturesAsyncReadCompatExt; + +impl FileSystemService { + pub async fn unzip_file(&self, zip_file: &str, target_dir: &str) -> ServiceResult { + let allowed_directories = self.allowed_directories().await; + + let zip_file = self.validate_path(Path::new(&zip_file), allowed_directories.clone())?; + let target_dir_path = self.validate_path(Path::new(target_dir), allowed_directories)?; + if !zip_file.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Zip file does not exists.", + ) + .into()); + } + + if target_dir_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("'{target_dir}' directory already exists!"), + ) + .into()); + } + + let file = BufReader::new(File::open(zip_file).await?); + let mut zip = ZipFileReader::with_tokio(file).await?; + + let file_count = zip.file().entries().len(); + + for index in 0..file_count { + let entry = zip.file().entries().get(index).unwrap(); + let entry_path = target_dir_path.join(entry.filename().as_str()?); + // Ensure the parent directory exists + if let Some(parent) = entry_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Extract the file + let reader = zip.reader_without_entry(index).await?; + let mut compat_reader = reader.compat(); + let mut output_file = File::create(&entry_path).await?; + + tokio::io::copy(&mut compat_reader, &mut output_file).await?; + output_file.flush().await?; + } + + let result_message = format!( + "Successfully extracted {} {} into '{}'.", + file_count, + if file_count == 1 { "file" } else { "files" }, + target_dir_path.display() + ); + + Ok(result_message) + } +} diff --git a/src/fs_service/archive/zip.rs b/src/fs_service/archive/zip.rs new file mode 100644 index 0000000..039dcf2 --- /dev/null +++ b/src/fs_service/archive/zip.rs @@ -0,0 +1,177 @@ +use crate::{ + error::ServiceResult, + fs_service::{ + FileSystemService, + utils::{format_bytes, write_zip_entry}, + }, +}; +use async_zip::tokio::write::ZipFileWriter; +use glob_match::glob_match; +use std::path::Path; +use tokio::fs::File; +use tokio_util::compat::TokioAsyncReadCompatExt; +use walkdir::WalkDir; + +impl FileSystemService { + pub async fn zip_directory( + &self, + input_dir: String, + pattern: String, + target_zip_file: String, + ) -> ServiceResult { + let allowed_directories = self.allowed_directories().await; + let valid_dir_path = + self.validate_path(Path::new(&input_dir), allowed_directories.clone())?; + + let input_dir_str = &valid_dir_path + .as_os_str() + .to_str() + .ok_or(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid UTF-8 in file name", + ))?; + + let target_path = + self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; + + if target_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("'{target_zip_file}' already exists!"), + ) + .into()); + } + + let updated_pattern = if pattern.contains('*') { + pattern.to_lowercase() + } else { + format!("*{}*", &pattern.to_lowercase()) + }; + + let glob_pattern = &updated_pattern; + + let entries: Vec<_> = WalkDir::new(&valid_dir_path) + .follow_links(true) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let full_path = entry.path(); + + self.validate_path(full_path, allowed_directories.clone()) + .ok() + .and_then(|path| { + if path != valid_dir_path + && glob_match(glob_pattern, path.display().to_string().as_ref()) + { + Some(path) + } else { + None + } + }) + }) + .collect(); + + let zip_file = File::create(&target_path).await?; + let mut zip_writer = ZipFileWriter::new(zip_file.compat()); + + for entry_path_buf in &entries { + if entry_path_buf.is_dir() { + continue; + } + let entry_path = entry_path_buf.as_path(); + let entry_str = entry_path.as_os_str().to_str().ok_or(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid UTF-8 in file name", + ))?; + + if !entry_str.starts_with(input_dir_str) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Entry file path does not start with base input directory path.", + ) + .into()); + } + + let entry_str = &entry_str[input_dir_str.len() + 1..]; + write_zip_entry(entry_str, entry_path, &mut zip_writer).await?; + } + + let z_file = zip_writer.close().await?; + let zip_file_size = if let Ok(meta_data) = z_file.into_inner().metadata().await { + format_bytes(meta_data.len()) + } else { + "unknown".to_string() + }; + let result_message = format!( + "Successfully compressed '{}' directory into '{}' ({}).", + input_dir, + target_path.display(), + zip_file_size + ); + Ok(result_message) + } + + pub async fn zip_files( + &self, + input_files: Vec, + target_zip_file: String, + ) -> ServiceResult { + let file_count = input_files.len(); + + if file_count == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "No file(s) to zip. The input files array is empty.", + ) + .into()); + } + let allowed_directories = self.allowed_directories().await; + let target_path = + self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; + + if target_path.exists() { + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("'{target_zip_file}' already exists!"), + ) + .into()); + } + + let source_paths = input_files + .iter() + .map(|p| self.validate_path(Path::new(p), allowed_directories.clone())) + .collect::, _>>()?; + + let zip_file = File::create(&target_path).await?; + let mut zip_writer = ZipFileWriter::new(zip_file.compat()); + for path in source_paths { + let filename = path.file_name().ok_or(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid path!", + ))?; + + let filename = filename.to_str().ok_or(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid UTF-8 in file name", + ))?; + + write_zip_entry(filename, &path, &mut zip_writer).await?; + } + let z_file = zip_writer.close().await?; + + let zip_file_size = if let Ok(meta_data) = z_file.into_inner().metadata().await { + format_bytes(meta_data.len()) + } else { + "unknown".to_string() + }; + + let result_message = format!( + "Successfully compressed {} {} into '{}' ({}).", + file_count, + if file_count == 1 { "file" } else { "files" }, + target_path.display(), + zip_file_size + ); + Ok(result_message) + } +} diff --git a/src/fs_service/core.rs b/src/fs_service/core.rs new file mode 100644 index 0000000..dc40c0d --- /dev/null +++ b/src/fs_service/core.rs @@ -0,0 +1,140 @@ +use crate::{ + error::{ServiceError, ServiceResult}, + fs_service::utils::{contains_symlink, expand_home, normalize_path, parse_file_path}, +}; +use std::{ + collections::HashSet, + env, + path::{Path, PathBuf}, + sync::Arc, +}; +use tokio::sync::RwLock; + +type PathResultList = Vec>; + +pub struct FileSystemService { + allowed_path: RwLock>>, +} + +impl FileSystemService { + pub fn try_new(allowed_directories: &[String]) -> ServiceResult { + let normalized_dirs: Vec = allowed_directories + .iter() + .map(fix_dockerhub_mcp_registry_gateway) + .map_while(|dir| { + let expand_result = expand_home(dir.into()); + if !expand_result.is_dir() { + panic!("{}", format!("Error: {dir} is not a directory")); + } + Some(expand_result) + }) + .collect(); + + Ok(Self { + allowed_path: RwLock::new(Arc::new(normalized_dirs)), + }) + } + + pub async fn allowed_directories(&self) -> Arc> { + let guard = self.allowed_path.read().await; + guard.clone() + } + + pub async fn update_allowed_paths(&self, valid_roots: Vec) { + let mut guard = self.allowed_path.write().await; + *guard = Arc::new(valid_roots) + } + + pub fn validate_path( + &self, + requested_path: &Path, + allowed_directories: Arc>, + ) -> ServiceResult { + if allowed_directories.is_empty() { + return Err(ServiceError::FromString( + "Allowed directories list is empty. Client did not provide any valid root directories.".to_string() + )); + } + + // Expand ~ to home directory + let expanded_path = expand_home(requested_path.to_path_buf()); + + // Resolve the absolute path + let absolute_path = if expanded_path.as_path().is_absolute() { + expanded_path.clone() + } else { + env::current_dir().unwrap().join(&expanded_path) + }; + + // Normalize the path + let normalized_requested = normalize_path(&absolute_path); + + // Check if path is within allowed directories + if !allowed_directories.iter().any(|dir| { + // Must account for both scenarios β€” the requested path may not exist yet, making canonicalization impossible. + normalized_requested.starts_with(dir) + || normalized_requested.starts_with(normalize_path(dir)) + }) { + let symlink_target = if contains_symlink(&absolute_path)? { + "a symlink target path" + } else { + "path" + }; + return Err(ServiceError::FromString(format!( + "Access denied - {} is outside allowed directories: {} not in {}", + symlink_target, + absolute_path.display(), + allowed_directories + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(",\n"), + ))); + } + + Ok(absolute_path) + } + + pub fn valid_roots(&self, roots: Vec<&str>) -> ServiceResult<(Vec, Option)> { + let paths: Vec> = + roots.iter().map(|p| parse_file_path(p)).collect::>(); + + // Partition into Ok and Err results + let (ok_paths, err_paths): (PathResultList, PathResultList) = + paths.into_iter().partition(|p| p.is_ok()); + + // using HashSet to remove duplicates + let (valid_roots, no_dir_roots): (HashSet, HashSet) = ok_paths + .into_iter() + .collect::, _>>()? + .into_iter() + .map(expand_home) + .partition(|path| path.is_dir()); + + let skipped_roots = if !err_paths.is_empty() || !no_dir_roots.is_empty() { + Some(format!( + "Warning: skipped {} invalid roots.", + err_paths.len() + no_dir_roots.len() + )) + } else { + None + }; + + let valid_roots = valid_roots.into_iter().collect(); + + Ok((valid_roots, skipped_roots)) + } +} + +/// This addresses the issue with the DockerHub mcp-registry & mcp-gateway where tool discovery fails to resolve +/// references to 'example' or 'default' values when running the run->command from the server.yaml file +/// should be removed once mcp-gateway is more mature +/// reference: https://github.com/docker/mcp-registry/blob/7d815fac2f3b7a9717eebc3f3db215de3ce3c3c7/internal/mcp/client.go#L170-L173 +#[allow(clippy::ptr_arg)] +fn fix_dockerhub_mcp_registry_gateway(input: &String) -> &str { + if input.contains("{{rust-mcp-filesystem.allowed_directories|volume-target|into}}") { + "." + } else { + input + } +} diff --git a/src/fs_service/file_info.rs b/src/fs_service/file_info.rs deleted file mode 100644 index fe75d73..0000000 --- a/src/fs_service/file_info.rs +++ /dev/null @@ -1,38 +0,0 @@ -use std::fs::{self}; -use std::time::SystemTime; - -use super::utils::{format_permissions, format_system_time}; - -#[derive(Debug)] -pub struct FileInfo { - pub size: u64, - pub created: Option, - pub modified: Option, - pub accessed: Option, - pub is_directory: bool, - pub is_file: bool, - pub metadata: fs::Metadata, -} - -impl std::fmt::Display for FileInfo { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - r#"size: {} -created: {} -modified: {} -accessed: {} -isDirectory: {} -isFile: {} -permissions: {} -"#, - self.size, - self.created.map_or("".to_string(), format_system_time), - self.modified.map_or("".to_string(), format_system_time), - self.accessed.map_or("".to_string(), format_system_time), - self.is_directory, - self.is_file, - format_permissions(&self.metadata) - ) - } -} diff --git a/src/fs_service/io.rs b/src/fs_service/io.rs new file mode 100644 index 0000000..ece4789 --- /dev/null +++ b/src/fs_service/io.rs @@ -0,0 +1,5 @@ +mod edit; +mod read; +mod write; + +pub use read::FileInfo; diff --git a/src/fs_service/io/edit.rs b/src/fs_service/io/edit.rs new file mode 100644 index 0000000..529ed73 --- /dev/null +++ b/src/fs_service/io/edit.rs @@ -0,0 +1,202 @@ +use crate::{ + error::ServiceResult, + fs_service::{ + FileSystemService, + utils::{detect_line_ending, normalize_line_endings}, + }, + tools::EditOperation, +}; +use rust_mcp_sdk::schema::RpcError; +use similar::TextDiff; +use std::path::Path; + +impl FileSystemService { + pub fn create_unified_diff( + &self, + original_content: &str, + new_content: &str, + filepath: Option, + ) -> String { + // Ensure consistent line endings for diff + let normalized_original = normalize_line_endings(original_content); + let normalized_new = normalize_line_endings(new_content); + + // // Generate the diff using TextDiff + let diff = TextDiff::from_lines(&normalized_original, &normalized_new); + + let file_name = filepath.unwrap_or("file".to_string()); + // Format the diff as a unified diff + let patch = diff + .unified_diff() + .header( + format!("{file_name}\toriginal").as_str(), + format!("{file_name}\tmodified").as_str(), + ) + .context_radius(4) + .to_string(); + + format!("Index: {}\n{}\n{}", file_name, "=".repeat(68), patch) + } + + pub async fn apply_file_edits( + &self, + file_path: &Path, + edits: Vec, + dry_run: Option, + save_to: Option<&Path>, + ) -> ServiceResult { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + + // Read file content and normalize line endings + let content_str = tokio::fs::read_to_string(&valid_path).await?; + let original_line_ending = detect_line_ending(&content_str); + let content_str = normalize_line_endings(&content_str); + + // Apply edits sequentially + let mut modified_content = content_str.clone(); + + for edit in edits { + let normalized_old = normalize_line_endings(&edit.old_text); + let normalized_new = normalize_line_endings(&edit.new_text); + // If exact match exists, use it + if modified_content.contains(&normalized_old) { + modified_content = modified_content.replacen(&normalized_old, &normalized_new, 1); + continue; + } + + // Otherwise, try line-by-line matching with flexibility for whitespace + let old_lines: Vec = normalized_old + .trim_end() + .split('\n') + .map(|s| s.to_string()) + .collect(); + + let content_lines: Vec = modified_content + .trim_end() + .split('\n') + .map(|s| s.to_string()) + .collect(); + + let mut match_found = false; + + // skip when the match is impossible: + if old_lines.len() > content_lines.len() { + let error_message = format!( + "Cannot apply edit: the original text spans more lines ({}) than the file content ({}).", + old_lines.len(), + content_lines.len() + ); + + return Err(RpcError::internal_error() + .with_message(error_message) + .into()); + } + + let max_start = content_lines.len().saturating_sub(old_lines.len()); + for i in 0..=max_start { + let potential_match = &content_lines[i..i + old_lines.len()]; + + // Compare lines with normalized whitespace + let is_match = old_lines.iter().enumerate().all(|(j, old_line)| { + let content_line = &potential_match[j]; + old_line.trim() == content_line.trim() + }); + + if is_match { + // Preserve original indentation of first line + let original_indent = content_lines[i] + .chars() + .take_while(|&c| c.is_whitespace()) + .collect::(); + + let new_lines: Vec = normalized_new + .split('\n') + .enumerate() + .map(|(j, line)| { + // Keep indentation of the first line + if j == 0 { + return format!("{}{}", original_indent, line.trim_start()); + } + + // For subsequent lines, preserve relative indentation and original whitespace type + let old_indent = old_lines + .get(j) + .map(|line| { + line.chars() + .take_while(|&c| c.is_whitespace()) + .collect::() + }) + .unwrap_or_default(); + + let new_indent = line + .chars() + .take_while(|&c| c.is_whitespace()) + .collect::(); + + // Use the same whitespace character as original_indent (tabs or spaces) + let indent_char = if original_indent.contains('\t') { + "\t" + } else { + " " + }; + let relative_indent = if new_indent.len() >= old_indent.len() { + new_indent.len() - old_indent.len() + } else { + 0 // Don't reduce indentation below original + }; + format!( + "{}{}{}", + &original_indent, + &indent_char.repeat(relative_indent), + line.trim_start() + ) + }) + .collect(); + + let mut content_lines = content_lines.clone(); + content_lines.splice(i..i + old_lines.len(), new_lines); + modified_content = content_lines.join("\n"); + match_found = true; + break; + } + } + if !match_found { + return Err(RpcError::internal_error() + .with_message(format!( + "Could not find exact match for edit:\n{}", + edit.old_text + )) + .into()); + } + } + + let diff = self.create_unified_diff( + &content_str, + &modified_content, + Some(valid_path.display().to_string()), + ); + + // Format diff with appropriate number of backticks + let mut num_backticks = 3; + while diff.contains(&"`".repeat(num_backticks)) { + num_backticks += 1; + } + let formatted_diff = format!( + "{}diff\n{}{}\n\n", + "`".repeat(num_backticks), + diff, + "`".repeat(num_backticks) + ); + + let is_dry_run = dry_run.unwrap_or(false); + + if !is_dry_run { + let target = save_to.unwrap_or(valid_path.as_path()); + let modified_content = modified_content.replace("\n", original_line_ending); + tokio::fs::write(target, modified_content).await?; + } + + Ok(formatted_diff) + } +} diff --git a/src/fs_service/io/read.rs b/src/fs_service/io/read.rs new file mode 100644 index 0000000..575d5b9 --- /dev/null +++ b/src/fs_service/io/read.rs @@ -0,0 +1,297 @@ +use crate::{ + error::ServiceResult, + fs_service::{ + FileSystemService, + utils::{ + format_permissions, format_system_time, mime_from_path, read_file_as_base64, + validate_file_size, + }, + }, +}; +use futures::{StreamExt, stream}; +use std::fs::{self}; +use std::time::SystemTime; +use std::{io::SeekFrom, path::Path}; +use tokio::{ + fs::File, + io::{AsyncBufReadExt, AsyncReadExt, AsyncSeekExt, BufReader}, +}; + +const MAX_CONCURRENT_FILE_READ: usize = 5; + +impl FileSystemService { + pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + let content = tokio::fs::read_to_string(valid_path).await?; + Ok(content) + } + + /// Reads the first n lines from a text file, preserving line endings. + /// Args: + /// file_path: Path to the file + /// n: Number of lines to read + /// Returns a String containing the first n lines with original line endings or an error if the path is invalid or file cannot be read. + pub async fn head_file(&self, file_path: &Path, n: usize) -> ServiceResult { + // Validate file path against allowed directories + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + + // Open file asynchronously and create a BufReader + let file = File::open(&valid_path).await?; + let mut reader = BufReader::new(file); + let mut result = String::with_capacity(n * 100); // Estimate capacity (avg 100 bytes/line) + let mut count = 0; + + // Read lines asynchronously, preserving line endings + let mut line = Vec::new(); + while count < n { + line.clear(); + let bytes_read = reader.read_until(b'\n', &mut line).await?; + if bytes_read == 0 { + break; // Reached EOF + } + result.push_str(&String::from_utf8_lossy(&line)); + count += 1; + } + + Ok(result) + } + + /// Reads the last n lines from a text file, preserving line endings. + /// Args: + /// file_path: Path to the file + /// n: Number of lines to read + /// Returns a String containing the last n lines with original line endings or an error if the path is invalid or file cannot be read. + pub async fn tail_file(&self, file_path: &Path, n: usize) -> ServiceResult { + // Validate file path against allowed directories + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + + // Open file asynchronously + let file = File::open(&valid_path).await?; + let file_size = file.metadata().await?.len(); + + // If file is empty or n is 0, return empty string + if file_size == 0 || n == 0 { + return Ok(String::new()); + } + + // Create a BufReader + let mut reader = BufReader::new(file); + let mut line_count = 0; + let mut pos = file_size; + let chunk_size = 8192; // 8KB chunks + let mut buffer = vec![0u8; chunk_size]; + let mut newline_positions = Vec::new(); + + // Read backwards to collect all newline positions + while pos > 0 { + let read_size = chunk_size.min(pos as usize); + pos -= read_size as u64; + reader.seek(SeekFrom::Start(pos)).await?; + let read_bytes = reader.read_exact(&mut buffer[..read_size]).await?; + + // Process chunk in reverse to find newlines + for (i, byte) in buffer[..read_bytes].iter().enumerate().rev() { + if *byte == b'\n' { + newline_positions.push(pos + i as u64); + line_count += 1; + } + } + } + + // Check if file ends with a non-newline character (partial last line) + if file_size > 0 { + let mut temp_reader = BufReader::new(File::open(&valid_path).await?); + temp_reader.seek(SeekFrom::End(-1)).await?; + let mut last_byte = [0u8; 1]; + temp_reader.read_exact(&mut last_byte).await?; + if last_byte[0] != b'\n' { + line_count += 1; + } + } + + // Determine start position for reading the last n lines + let start_pos = if line_count <= n { + 0 // Read from start if fewer than n lines + } else { + *newline_positions.get(line_count - n).unwrap_or(&0) + 1 + }; + + // Read forward from start_pos + reader.seek(SeekFrom::Start(start_pos)).await?; + let mut result = String::with_capacity(n * 100); // Estimate capacity + let mut line = Vec::new(); + let mut lines_read = 0; + + while lines_read < n { + line.clear(); + let bytes_read = reader.read_until(b'\n', &mut line).await?; + if bytes_read == 0 { + // Handle partial last line at EOF + if !line.is_empty() { + result.push_str(&String::from_utf8_lossy(&line)); + } + break; + } + result.push_str(&String::from_utf8_lossy(&line)); + lines_read += 1; + } + + Ok(result) + } + + /// Reads lines from a text file starting at the specified offset (0-based), preserving line endings. + /// Args: + /// path: Path to the file + /// offset: Number of lines to skip (0-based) + /// limit: Optional maximum number of lines to read + /// Returns a String containing the selected lines with original line endings or an error if the path is invalid or file cannot be read. + pub async fn read_file_lines( + &self, + path: &Path, + offset: usize, + limit: Option, + ) -> ServiceResult { + // Validate file path against allowed directories + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(path, allowed_directories)?; + + // Open file and get metadata before moving into BufReader + let file = File::open(&valid_path).await?; + let file_size = file.metadata().await?.len(); + let mut reader = BufReader::new(file); + + // If file is empty or limit is 0, return empty string + if file_size == 0 || limit == Some(0) { + return Ok(String::new()); + } + + // Skip offset lines (0-based indexing) + let mut buffer = Vec::new(); + for _ in 0..offset { + buffer.clear(); + if reader.read_until(b'\n', &mut buffer).await? == 0 { + return Ok(String::new()); // EOF before offset + } + } + + // Read lines up to limit (or all remaining if limit is None) + let mut result = String::with_capacity(limit.unwrap_or(100) * 100); // Estimate capacity + match limit { + Some(max_lines) => { + for _ in 0..max_lines { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await?; + if bytes_read == 0 { + break; // Reached EOF + } + result.push_str(&String::from_utf8_lossy(&buffer)); + } + } + None => { + loop { + buffer.clear(); + let bytes_read = reader.read_until(b'\n', &mut buffer).await?; + if bytes_read == 0 { + break; // Reached EOF + } + result.push_str(&String::from_utf8_lossy(&buffer)); + } + } + } + + Ok(result) + } + + pub async fn read_media_files( + &self, + paths: Vec, + max_bytes: Option, + ) -> ServiceResult> { + let results = stream::iter(paths) + .map(|path| async { + self.read_media_file(Path::new(&path), max_bytes) + .await + .map_err(|e| (path, e)) + }) + .buffer_unordered(MAX_CONCURRENT_FILE_READ) // Process up to MAX_CONCURRENT_FILE_READ files concurrently + .filter_map(|result| async move { result.ok() }) + .collect::>() + .await; + Ok(results) + } + + pub async fn read_media_file( + &self, + file_path: &Path, + max_bytes: Option, + ) -> ServiceResult<(infer::Type, String)> { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + validate_file_size(&valid_path, None, max_bytes).await?; + let kind = mime_from_path(&valid_path)?; + let content = read_file_as_base64(&valid_path).await?; + Ok((kind, content)) + } + + // Get file stats + pub async fn get_file_stats(&self, file_path: &Path) -> ServiceResult { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + + let metadata = std::fs::metadata(valid_path)?; + + let size = metadata.len(); + let created = metadata.created().ok(); + let modified = metadata.modified().ok(); + let accessed = metadata.accessed().ok(); + let is_directory = metadata.is_dir(); + let is_file = metadata.is_file(); + + Ok(FileInfo { + size, + created, + modified, + accessed, + is_directory, + is_file, + metadata, + }) + } +} + +#[derive(Debug)] +pub struct FileInfo { + pub size: u64, + pub created: Option, + pub modified: Option, + pub accessed: Option, + pub is_directory: bool, + pub is_file: bool, + pub metadata: fs::Metadata, +} + +impl std::fmt::Display for FileInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + r#"size: {} +created: {} +modified: {} +accessed: {} +isDirectory: {} +isFile: {} +permissions: {} +"#, + self.size, + self.created.map_or("".to_string(), format_system_time), + self.modified.map_or("".to_string(), format_system_time), + self.accessed.map_or("".to_string(), format_system_time), + self.is_directory, + self.is_file, + format_permissions(&self.metadata) + ) + } +} diff --git a/src/fs_service/io/write.rs b/src/fs_service/io/write.rs new file mode 100644 index 0000000..907d42d --- /dev/null +++ b/src/fs_service/io/write.rs @@ -0,0 +1,26 @@ +use crate::{error::ServiceResult, fs_service::FileSystemService}; +use std::path::Path; + +impl FileSystemService { + pub async fn write_file(&self, file_path: &Path, content: &String) -> ServiceResult<()> { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + tokio::fs::write(valid_path, content).await?; + Ok(()) + } + + pub async fn create_directory(&self, file_path: &Path) -> ServiceResult<()> { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + tokio::fs::create_dir_all(valid_path).await?; + Ok(()) + } + + pub async fn move_file(&self, src_path: &Path, dest_path: &Path) -> ServiceResult<()> { + let allowed_directories = self.allowed_directories().await; + let valid_src_path = self.validate_path(src_path, allowed_directories.clone())?; + let valid_dest_path = self.validate_path(dest_path, allowed_directories)?; + tokio::fs::rename(valid_src_path, valid_dest_path).await?; + Ok(()) + } +} diff --git a/src/fs_service/search.rs b/src/fs_service/search.rs new file mode 100644 index 0000000..691c821 --- /dev/null +++ b/src/fs_service/search.rs @@ -0,0 +1,5 @@ +mod content; +mod files; +mod tree; + +pub use content::FileSearchResult; diff --git a/src/fs_service/search/content.rs b/src/fs_service/search/content.rs new file mode 100644 index 0000000..feb2a30 --- /dev/null +++ b/src/fs_service/search/content.rs @@ -0,0 +1,197 @@ +use crate::{ + error::ServiceResult, + fs_service::{FileSystemService, utils::escape_regex}, +}; +use grep::{ + matcher::{Match, Matcher}, + regex::RegexMatcherBuilder, + searcher::{BinaryDetection, Searcher, sinks::UTF8}, +}; +use std::path::{Path, PathBuf}; + +const SNIPPET_MAX_LENGTH: usize = 200; +const SNIPPET_BACKWARD_CHARS: usize = 30; + +/// Represents a single match found in a file's content. +#[derive(Debug, Clone)] +pub struct ContentMatchResult { + /// The line number where the match occurred (1-based). + pub line_number: u64, + pub start_pos: usize, + /// The line of text containing the match. + /// If the line exceeds 255 characters (excluding the search term), only a truncated portion will be shown. + pub line_text: String, +} + +/// Represents all matches found in a specific file. +#[derive(Debug, Clone)] +pub struct FileSearchResult { + /// The path to the file where matches were found. + pub file_path: PathBuf, + /// All individual match results within the file. + pub matches: Vec, +} + +impl FileSystemService { + // Searches the content of a file for occurrences of the given query string. + /// + /// This method searches the file specified by `file_path` for lines matching the `query`. + /// The search can be performed as a regular expression or as a literal string, + /// depending on the `is_regex` flag. + /// + /// If matched line is larger than 255 characters, a snippet will be extracted around the matched text. + /// + pub fn content_search( + &self, + query: &str, + file_path: impl AsRef, + is_regex: Option, + ) -> ServiceResult> { + let query = if is_regex.unwrap_or_default() { + query.to_string() + } else { + escape_regex(query) + }; + + let matcher = RegexMatcherBuilder::new() + .case_insensitive(true) + .build(query.as_str())?; + + let mut searcher = Searcher::new(); + let mut result = FileSearchResult { + file_path: file_path.as_ref().to_path_buf(), + matches: vec![], + }; + + searcher.set_binary_detection(BinaryDetection::quit(b'\x00')); + + searcher.search_path( + &matcher, + file_path, + UTF8(|line_number, line| { + let actual_match = matcher.find(line.as_bytes())?.unwrap(); + + result.matches.push(ContentMatchResult { + line_number, + start_pos: actual_match.start(), + line_text: self.extract_snippet(line, actual_match, None, None), + }); + Ok(true) + }), + )?; + + if result.matches.is_empty() { + return Ok(None); + } + + Ok(Some(result)) + } + + /// Extracts a snippet from a given line of text around a match. + /// + /// It extracts a substring starting a fixed number of characters (`SNIPPET_BACKWARD_CHARS`) + /// before the start position of the `match`, and extends up to `max_length` characters + /// If the snippet does not include the beginning or end of the original line, ellipses (`"..."`) are added + /// to indicate the truncation. + pub fn extract_snippet( + &self, + line: &str, + match_result: Match, + max_length: Option, + backward_chars: Option, + ) -> String { + let max_length = max_length.unwrap_or(SNIPPET_MAX_LENGTH); + let backward_chars = backward_chars.unwrap_or(SNIPPET_BACKWARD_CHARS); + + // Calculate the number of leading whitespace bytes to adjust for trimmed input + let start_pos = line.len() - line.trim_start().len(); + // Trim leading and trailing whitespace from the input line + let line = line.trim(); + + // Calculate the desired start byte index by adjusting match start for trimming and backward chars + // match_result.start() is the byte index in the original string + // Subtract start_pos to account for trimmed whitespace and backward_chars to include context before the match + let desired_start = (match_result.start() - start_pos).saturating_sub(backward_chars); + + // Find the nearest valid UTF-8 character boundary at or after desired_start + // Prevents "byte index is not a char boundary" panic by ensuring the slice starts at a valid character (issue #37) + let snippet_start = line + .char_indices() + .map(|(i, _)| i) + .find(|&i| i >= desired_start) + .unwrap_or(desired_start.min(line.len())); + // Initialize a counter for tracking characters to respect max_length + let mut char_count = 0; + + // Calculate the desired end byte index by counting max_length characters from snippet_start + // Take max_length + 1 to find the boundary after the last desired character + let desired_end = line[snippet_start..] + .char_indices() + .take(max_length + 1) + .find(|&(_, _)| { + char_count += 1; + char_count > max_length + }) + .map(|(i, _)| snippet_start + i) + .unwrap_or(line.len()); + + // Ensure snippet_end is a valid UTF-8 character boundary at or after desired_end + // This prevents slicing issues with multi-byte characters + let snippet_end = line + .char_indices() + .map(|(i, _)| i) + .find(|&i| i >= desired_end) + .unwrap_or(line.len()); + + // Cap snippet_end to avoid exceeding the string length + let snippet_end = snippet_end.min(line.len()); + + // Extract the snippet from the trimmed line using the calculated byte indices + let snippet = &line[snippet_start..snippet_end]; + + let mut result = String::new(); + // Add leading ellipsis if the snippet doesn't start at the beginning of the trimmed line + if snippet_start > 0 { + result.push_str("..."); + } + + result.push_str(snippet); + + // Add trailing ellipsis if the snippet doesn't reach the end of the trimmed line + if snippet_end < line.len() { + result.push_str("..."); + } + result + } + + #[allow(clippy::too_many_arguments)] + pub async fn search_files_content( + &self, + root_path: impl AsRef, + pattern: &str, + query: &str, + is_regex: bool, + exclude_patterns: Option>, + min_bytes: Option, + max_bytes: Option, + ) -> ServiceResult> { + let files_iter = self + .search_files_iter( + root_path.as_ref(), + pattern.to_string(), + exclude_patterns.to_owned().unwrap_or_default(), + min_bytes, + max_bytes, + ) + .await?; + + let results: Vec = files_iter + .filter_map(|entry| { + self.content_search(query, entry.path(), Some(is_regex)) + .ok() + .and_then(|v| v) + }) + .collect(); + Ok(results) + } +} diff --git a/src/fs_service/search/files.rs b/src/fs_service/search/files.rs new file mode 100644 index 0000000..b40b1b3 --- /dev/null +++ b/src/fs_service/search/files.rs @@ -0,0 +1,248 @@ +use crate::{ + error::ServiceResult, + fs_service::{FileSystemService, utils::filesize_in_range}, +}; +use glob_match::glob_match; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use sha2::{Digest, Sha256}; +use std::{collections::HashMap, path::Path}; +use tokio::{fs::File, io::AsyncReadExt}; +use walkdir::WalkDir; + +impl FileSystemService { + /// Searches for files in the directory tree starting at `root_path` that match the given `pattern`, + /// excluding paths that match any of the `exclude_patterns`. + /// + /// # Arguments + /// * `root_path` - The root directory to start the search from. + /// * `pattern` - A glob pattern to match file names (case-insensitive). If no wildcards are provided, + /// the pattern is wrapped in '*' for partial matching. + /// * `exclude_patterns` - A list of glob patterns to exclude paths (case-sensitive). + /// + /// # Returns + /// A `ServiceResult` containing a vector of`walkdir::DirEntry` objects for matching files, + /// or a `ServiceError` if an error occurs. + pub async fn search_files( + &self, + root_path: &Path, + pattern: String, + exclude_patterns: Vec, + min_bytes: Option, + max_bytes: Option, + ) -> ServiceResult> { + let result = self + .search_files_iter(root_path, pattern, exclude_patterns, min_bytes, max_bytes) + .await?; + Ok(result.collect::>()) + } + + /// Returns an iterator over files in the directory tree starting at `root_path` that match + /// the given `pattern`, excluding paths that match any of the `exclude_patterns`. + /// + /// # Arguments + /// * `root_path` - The root directory to start the search from. + /// * `pattern` - A glob pattern to match file names. If no wildcards are provided, the pattern is wrapped in `**/*{pattern}*` for partial matching. + /// * `exclude_patterns` - A list of glob patterns to exclude paths (case-sensitive). + /// + /// # Returns + /// A `ServiceResult` containing an iterator yielding `walkdir::DirEntry` objects for matching files, + /// or a `ServiceError` if an error occurs. + pub async fn search_files_iter<'a>( + &'a self, + // root_path: impl Into, + root_path: &'a Path, + pattern: String, + exclude_patterns: Vec, + min_bytes: Option, + max_bytes: Option, + ) -> ServiceResult + 'a> { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(root_path, allowed_directories.clone())?; + + let updated_pattern = if pattern.contains('*') { + pattern.to_lowercase() + } else { + format!("**/*{}*", &pattern.to_lowercase()) + }; + let glob_pattern = updated_pattern; + + let result = WalkDir::new(valid_path) + .follow_links(true) + .into_iter() + .filter_entry(move |dir_entry| { + let full_path = dir_entry.path(); + + // Validate each path before processing + let validated_path = self + .validate_path(full_path, allowed_directories.clone()) + .ok(); + + if validated_path.is_none() { + // Skip invalid paths during search + return false; + } + + // Get the relative path from the root_path + let relative_path = full_path.strip_prefix(root_path).unwrap_or(full_path); + + let mut should_exclude = exclude_patterns.iter().any(|pattern| { + let glob_pattern = if pattern.contains('*') { + pattern.strip_prefix("/").unwrap_or(pattern).to_owned() + } else { + format!("*{pattern}*") + }; + + glob_match(&glob_pattern, relative_path.to_str().unwrap_or("")) + }); + + // enforce min/max bytes + if !should_exclude && (min_bytes.is_none() || max_bytes.is_none()) { + match dir_entry.metadata().ok() { + Some(metadata) => { + if !filesize_in_range(metadata.len(), min_bytes, max_bytes) { + should_exclude = true; + } + } + None => { + should_exclude = true; + } + } + } + + !should_exclude + }) + .filter_map(|v| v.ok()) + .filter(move |entry| { + if root_path == entry.path() { + return false; + } + + glob_match( + &glob_pattern, + &entry.file_name().to_str().unwrap_or("").to_lowercase(), + ) + }); + + Ok(result) + } + + /// Finds groups of duplicate files within the given root path. + /// Returns a vector of vectors, where each inner vector contains paths to files with identical content. + /// Files are considered duplicates if they have the same size and SHA-256 hash. + pub async fn find_duplicate_files( + &self, + root_path: &Path, + pattern: Option, + exclude_patterns: Option>, + min_bytes: Option, + max_bytes: Option, + ) -> ServiceResult>> { + // Validate root path against allowed directories + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(root_path, allowed_directories)?; + + // Get Tokio runtime handle + let rt = tokio::runtime::Handle::current(); + + // Step 1: Collect files and group by size + let mut size_map: HashMap> = HashMap::new(); + let entries = self + .search_files_iter( + &valid_path, + pattern.unwrap_or("**/*".to_string()), + exclude_patterns.unwrap_or_default(), + min_bytes, + max_bytes, + ) + .await? + .filter(|e| e.file_type().is_file()); // Only files + + for entry in entries { + if let Ok(metadata) = entry.metadata() { + if let Some(path_str) = entry.path().to_str() { + size_map + .entry(metadata.len()) + .or_default() + .push(path_str.to_string()); + } + } + } + + // Filter out sizes with only one file (no duplicates possible) + let size_groups: Vec> = size_map + .into_iter() + .collect::>() // Collect into Vec to enable parallel iteration + .into_par_iter() + .filter(|(_, paths)| paths.len() > 1) + .map(|(_, paths)| paths) + .collect(); + + // Step 2: Group by quick hash (first 4KB) + let mut quick_hash_map: HashMap, Vec> = HashMap::new(); + for paths in size_groups.into_iter() { + let quick_hashes: Vec<(String, Vec)> = paths + .into_par_iter() + .filter_map(|path| { + let rt = rt.clone(); // Clone the runtime handle for this task + rt.block_on(async { + let file = File::open(&path).await.ok()?; + let mut reader = tokio::io::BufReader::new(file); + let mut buffer = vec![0u8; 4096]; // Read first 4KB + let bytes_read = reader.read(&mut buffer).await.ok()?; + let mut hasher = Sha256::new(); + hasher.update(&buffer[..bytes_read]); + Some((path, hasher.finalize().to_vec())) + }) + }) + .collect(); + + for (path, hash) in quick_hashes { + quick_hash_map.entry(hash).or_default().push(path); + } + } + + // Step 3: Group by full hash for groups with multiple files + let mut full_hash_map: HashMap, Vec> = HashMap::new(); + let filtered_quick_hashes: Vec<(Vec, Vec)> = quick_hash_map + .into_iter() + .collect::>() + .into_par_iter() + .filter(|(_, paths)| paths.len() > 1) + .collect(); + + for (_quick_hash, paths) in filtered_quick_hashes { + let full_hashes: Vec<(String, Vec)> = paths + .into_par_iter() + .filter_map(|path| { + let rt = rt.clone(); // Clone the runtime handle for this task + rt.block_on(async { + let file = File::open(&path).await.ok()?; + let mut reader = tokio::io::BufReader::new(file); + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 8192]; // 8KB chunks + loop { + let bytes_read = reader.read(&mut buffer).await.ok()?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + Some((path, hasher.finalize().to_vec())) + }) + }) + .collect(); + + for (path, hash) in full_hashes { + full_hash_map.entry(hash).or_default().push(path); + } + } + + // Collect groups of duplicates (only groups with more than one file) + let duplicates: Vec> = full_hash_map + .into_values() + .filter(|group| group.len() > 1) + .collect(); + + Ok(duplicates) + } +} diff --git a/src/fs_service/search/tree.rs b/src/fs_service/search/tree.rs new file mode 100644 index 0000000..d0d29fa --- /dev/null +++ b/src/fs_service/search/tree.rs @@ -0,0 +1,220 @@ +use crate::{ + error::{ServiceError, ServiceResult}, + fs_service::{FileSystemService, utils::is_system_metadata_file}, +}; +use rayon::iter::{ParallelBridge, ParallelIterator}; +use serde_json::{Value, json}; +use std::{ + fs::{self}, + path::{Path, PathBuf}, + sync::Arc, +}; +use walkdir::WalkDir; + +impl FileSystemService { + /// Generates a JSON representation of a directory tree starting at the given path. + /// + /// This function recursively builds a JSON array object representing the directory structure, + /// where each entry includes a `name` (file or directory name), `type` ("file" or "directory"), + /// and for directories, a `children` array containing their contents. Files do not have a + /// `children` field. + /// + /// The function supports optional constraints to limit the tree size: + /// - `max_depth`: Limits the depth of directory traversal. + /// - `max_files`: Limits the total number of entries (files and directories). + /// + /// # IMPORTANT NOTE + /// + /// use max_depth or max_files could lead to partial or skewed representations of actual directory tree + pub fn directory_tree>( + &self, + root_path: P, + max_depth: Option, + max_files: Option, + current_count: &mut usize, + allowed_directories: Arc>, + ) -> ServiceResult<(Value, bool)> { + let valid_path = self.validate_path(root_path.as_ref(), allowed_directories.clone())?; + + let metadata = fs::metadata(&valid_path)?; + if !metadata.is_dir() { + return Err(ServiceError::FromString( + "Root path must be a directory".into(), + )); + } + + let mut children = Vec::new(); + let mut reached_max_depth = false; + + if max_depth != Some(0) { + for entry in WalkDir::new(valid_path) + .min_depth(1) + .max_depth(1) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let child_path = entry.path(); + let metadata = fs::metadata(child_path)?; + + let entry_name = child_path + .file_name() + .ok_or(ServiceError::FromString("Invalid path".to_string()))? + .to_string_lossy() + .into_owned(); + + // Increment the count for this entry + *current_count += 1; + + // Check if we've exceeded max_files (if set) + if let Some(max) = max_files { + if *current_count > max { + continue; // Skip this entry but continue processing others + } + } + + let mut json_entry = json!({ + "name": entry_name, + "type": if metadata.is_dir() { "directory" } else { "file" } + }); + + if metadata.is_dir() { + let next_depth = max_depth.map(|d| d - 1); + let (child_children, child_reached_max_depth) = self.directory_tree( + child_path, + next_depth, + max_files, + current_count, + allowed_directories.clone(), + )?; + json_entry + .as_object_mut() + .unwrap() + .insert("children".to_string(), child_children); + reached_max_depth |= child_reached_max_depth; + } + children.push(json_entry); + } + } else { + // If max_depth is 0, we skip processing this directory's children + reached_max_depth = true; + } + Ok((Value::Array(children), reached_max_depth)) + } + + /// Calculates the total size (in bytes) of all files within a directory tree. + /// + /// This function recursively searches the specified `root_path` for files, + /// filters out directories and non-file entries, and sums the sizes of all found files. + /// The size calculation is parallelized using Rayon for improved performance on large directories. + /// + /// # Arguments + /// * `root_path` - The root directory path to start the size calculation. + /// + /// # Returns + /// Returns a `ServiceResult` containing the total size in bytes of all files under the `root_path`. + /// + /// # Notes + /// - Only files are included in the size calculation; directories and other non-file entries are ignored. + /// - The search pattern is `"**/*"` (all files) and no exclusions are applied. + /// - Parallel iteration is used to speed up the metadata fetching and summation. + pub async fn calculate_directory_size(&self, root_path: &Path) -> ServiceResult { + let entries = self + .search_files_iter(root_path, "**/*".to_string(), vec![], None, None) + .await? + .filter(|e| e.file_type().is_file()); // Only process files + + // Use rayon to parallelize size summation + let total_size: u64 = entries + .par_bridge() // Convert to parallel iterator + .filter_map(|entry| entry.metadata().ok().map(|meta| meta.len())) + .sum(); + + Ok(total_size) + } + + /// Recursively finds all empty directories within the given root path. + /// + /// A directory is considered empty if it contains no files in itself or any of its subdirectories + /// except OS metadata files: `.DS_Store` (macOS) and `Thumbs.db` (Windows) + /// Empty subdirectories are allowed. You can optionally provide a list of glob-style patterns in + /// `exclude_patterns` to ignore certain paths during the search (e.g., to skip system folders or hidden directories). + /// + /// # Arguments + /// - `root_path`: The starting directory to search. + /// - `exclude_patterns`: Optional list of glob patterns to exclude from the search. + /// Directories matching these patterns will be ignored. + /// + /// # Errors + /// Returns an error if the root path is invalid or inaccessible. + /// + /// # Returns + /// A list of paths to empty directories, as strings, including parent directories that contain only empty subdirectories. + /// Recursively finds all empty directories within the given root path. + /// + /// A directory is considered empty if it contains no files in itself or any of its subdirectories. + /// Empty subdirectories are allowed. You can optionally provide a list of glob-style patterns in + /// `exclude_patterns` to ignore certain paths during the search (e.g., to skip system folders or hidden directories). + /// + /// # Arguments + /// - `root_path`: The starting directory to search. + /// - `exclude_patterns`: Optional list of glob patterns to exclude from the search. + /// Directories matching these patterns will be ignored. + /// + /// # Errors + /// Returns an error if the root path is invalid or inaccessible. + /// + /// # Returns + /// A list of paths to all empty directories, as strings, including parent directories that contain only empty subdirectories. + pub async fn find_empty_directories( + &self, + root_path: &Path, + exclude_patterns: Option>, + ) -> ServiceResult> { + let walker = self + .search_files_iter( + root_path, + "**/*".to_string(), + exclude_patterns.unwrap_or_default(), + None, + None, + ) + .await? + .filter(|e| e.file_type().is_dir()); // Only directories + + let mut empty_dirs = Vec::new(); + + // Check each directory for emptiness + for entry in walker { + let is_empty = WalkDir::new(entry.path()) + .into_iter() + .filter_map(|e| e.ok()) + .all(|e| !e.file_type().is_file() || is_system_metadata_file(e.file_name())); // Directory is empty if no files are found in it or subdirs, ".DS_Store" will be ignores on Mac + + if is_empty { + if let Some(path_str) = entry.path().to_str() { + empty_dirs.push(path_str.to_string()); + } + } + } + + Ok(empty_dirs) + } + + pub async fn list_directory(&self, dir_path: &Path) -> ServiceResult> { + let allowed_directories = self.allowed_directories().await; + + let valid_path = self.validate_path(dir_path, allowed_directories)?; + + let mut dir = tokio::fs::read_dir(valid_path).await?; + + let mut entries = Vec::new(); + + // Use a loop to collect the directory entries + while let Some(entry) = dir.next_entry().await? { + entries.push(entry); + } + + Ok(entries) + } +} diff --git a/src/fs_service/utils.rs b/src/fs_service/utils.rs index a7ca00c..d818218 100644 --- a/src/fs_service/utils.rs +++ b/src/fs_service/utils.rs @@ -1,7 +1,10 @@ +use crate::error::{ServiceError, ServiceResult}; use async_zip::{Compression, ZipEntryBuilder, error::ZipError, tokio::write::ZipFileWriter}; +use base64::{engine::general_purpose, write::EncoderWriter}; use chrono::{DateTime, Local}; use dirs::home_dir; use rust_mcp_sdk::macros::JsonSchema; +use std::io::Write; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; #[cfg(windows)] @@ -12,8 +15,16 @@ use std::{ path::{Component, Path, PathBuf, Prefix}, time::SystemTime, }; -use tokio::fs::File; use tokio::io::AsyncReadExt; +use tokio::{ + fs::{File, metadata}, + io::BufReader, +}; + +#[cfg(windows)] +pub const OS_LINE_ENDING: &str = "\r\n"; +#[cfg(not(windows))] +pub const OS_LINE_ENDING: &str = "\n"; #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] pub enum OutputFormat { @@ -157,3 +168,117 @@ pub fn contains_symlink>(path: P) -> std::io::Result { pub fn is_system_metadata_file(filename: &OsStr) -> bool { filename == ".DS_Store" || filename == "Thumbs.db" } + +// reads file as base64 efficiently in a streaming manner +pub async fn read_file_as_base64(file_path: &Path) -> ServiceResult { + let file = File::open(file_path).await?; + let mut reader = BufReader::new(file); + + let mut output = Vec::new(); + { + // Wrap output Vec in a Base64 encoder writer + let mut encoder = EncoderWriter::new(&mut output, &general_purpose::STANDARD); + + let mut buffer = [0u8; 8192]; + loop { + let n = reader.read(&mut buffer).await?; + if n == 0 { + break; + } + // Write raw bytes to the Base64 encoder + encoder.write_all(&buffer[..n])?; + } + // Make sure to flush any remaining bytes + encoder.flush()?; + } // drop encoder before consuming output + + // Convert the Base64 bytes to String (safe UTF-8) + let base64_string = + String::from_utf8(output).map_err(|err| ServiceError::FromString(format!("{err}")))?; + Ok(base64_string) +} + +pub fn detect_line_ending(text: &str) -> &str { + if text.contains("\r\n") { + "\r\n" + } else if text.contains('\r') { + "\r" + } else { + "\n" + } +} + +pub fn mime_from_path(path: &Path) -> ServiceResult { + let is_svg = path + .extension() + .is_some_and(|e| e.to_str().is_some_and(|s| s == "svg")); + // consider it is a svg file as we cannot detect svg from bytes pattern + if is_svg { + return Ok(infer::Type::new( + infer::MatcherType::Image, + "image/svg+xml", + "svg", + |_: &[u8]| true, + )); + + // infer::Type::new(infer::MatcherType::Image, "", "svg",); + } + let kind = infer::get_from_path(path)?.ok_or(ServiceError::FromString( + "File tyle is unknown!".to_string(), + ))?; + Ok(kind) +} + +pub fn escape_regex(text: &str) -> String { + // Covers special characters in regex engines (RE2, PCRE, JS, Python) + const SPECIAL_CHARS: &[char] = &[ + '.', '^', '$', '*', '+', '?', '(', ')', '[', ']', '{', '}', '\\', '|', '/', + ]; + + let mut escaped = String::with_capacity(text.len()); + + for ch in text.chars() { + if SPECIAL_CHARS.contains(&ch) { + escaped.push('\\'); + } + escaped.push(ch); + } + + escaped +} + +pub fn filesize_in_range(file_size: u64, min_bytes: Option, max_bytes: Option) -> bool { + if min_bytes.is_none() && max_bytes.is_none() { + return true; + } + match (min_bytes, max_bytes) { + (_, Some(max)) if file_size > max => false, + (Some(min), _) if file_size < min => false, + _ => true, + } +} + +pub async fn validate_file_size>( + path: P, + min_bytes: Option, + max_bytes: Option, +) -> ServiceResult<()> { + if min_bytes.is_none() && max_bytes.is_none() { + return Ok(()); + } + + let file_size = metadata(&path).await?.len() as usize; + + match (min_bytes, max_bytes) { + (_, Some(max)) if file_size > max => Err(ServiceError::FileTooLarge(max)), + (Some(min), _) if file_size < min => Err(ServiceError::FileTooSmall(min)), + _ => Ok(()), + } +} + +/// Converts a string to a `PathBuf`, supporting both raw paths and `file://` URIs. +pub fn parse_file_path(input: &str) -> ServiceResult { + Ok(PathBuf::from( + input.strip_prefix("file://").unwrap_or(input).trim(), + )) +} diff --git a/tests/common/common.rs b/tests/common/common.rs index d73a750..fedd043 100644 --- a/tests/common/common.rs +++ b/tests/common/common.rs @@ -8,7 +8,7 @@ use std::{ use clap::Parser; use rust_mcp_filesystem::{ cli::CommandArguments, - fs_service::{FileSystemService, file_info::FileInfo}, + fs_service::{FileInfo, FileSystemService}, }; use tempfile::TempDir; diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 9bd7bd3..df67283 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -10,8 +10,8 @@ use common::setup_service; use dirs::home_dir; use grep::matcher::Match; use rust_mcp_filesystem::error::ServiceError; +use rust_mcp_filesystem::fs_service::FileInfo; use rust_mcp_filesystem::fs_service::FileSystemService; -use rust_mcp_filesystem::fs_service::file_info::FileInfo; use rust_mcp_filesystem::fs_service::utils::*; use rust_mcp_filesystem::tools::EditOperation; use std::fs::{self, File}; From 5070a17b7aad713e6bfc083e42935d30d2a64de9 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 19:27:08 -0300 Subject: [PATCH 2/6] chore: update dependencies --- Cargo.lock | 435 ++++++++++++++++++----------------------------------- 1 file changed, 144 insertions(+), 291 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bea0a9a..8c7d802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -19,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -131,21 +122,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - [[package]] name = "base64" version = "0.22.1" @@ -154,9 +130,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" @@ -169,9 +145,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234113d19d0d7d613b40e86fb654acf958910802bcceab913a4f9e7cda03b1a4" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "regex-automata", @@ -198,18 +174,18 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bzip2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ "libbz2-rs-sys", ] [[package]] name = "cc" -version = "1.2.40" +version = "1.2.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" dependencies = [ "find-msvc-tools", "jobserver", @@ -230,9 +206,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chrono" @@ -249,9 +225,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" dependencies = [ "clap_builder", "clap_derive", @@ -259,9 +235,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" dependencies = [ "anstream", "anstyle", @@ -271,9 +247,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", @@ -283,9 +259,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "colorchoice" @@ -407,7 +383,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -441,7 +417,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -452,15 +428,15 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "find-msvc-tools" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", @@ -576,9 +552,9 @@ dependencies = [ [[package]] name = "generic-array" -version = "0.14.7" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", @@ -592,27 +568,21 @@ checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - [[package]] name = "glob-match" version = "0.2.1" @@ -621,9 +591,9 @@ checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" [[package]] name = "globset" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", @@ -647,9 +617,9 @@ dependencies = [ [[package]] name = "grep-cli" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47f1288f0e06f279f84926fa4c17e3fcd2a22b357927a82f2777f7be26e4cec0" +checksum = "cf32d263c5d5cc2a23ce587097f5ddafdb188492ba2e6fb638eaccdc22453631" dependencies = [ "bstr", "globset", @@ -661,9 +631,9 @@ dependencies = [ [[package]] name = "grep-matcher" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a3141a10a43acfedc7c98a60a834d7ba00dfe7bec9071cbfc19b55b292ac02" +checksum = "36d7b71093325ab22d780b40d7df3066ae4aebb518ba719d38c697a8228a8023" dependencies = [ "memchr", ] @@ -685,9 +655,9 @@ dependencies = [ [[package]] name = "grep-regex" -version = "0.1.13" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edd147c7e3296e7a26bd3a81345ce849557d5a8e48ed88f736074e760f91f7e" +checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef" dependencies = [ "bstr", "grep-matcher", @@ -698,9 +668,9 @@ dependencies = [ [[package]] name = "grep-searcher" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b6c14b3fc2e0a107d6604d3231dec0509e691e62447104bc385a46a7892cda" +checksum = "ac63295322dc48ebb20a25348147905d816318888e64f531bfc2a2bc0577dc34" dependencies = [ "bstr", "encoding_rs", @@ -750,22 +720,11 @@ dependencies = [ "cfb", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags", - "cfg-if", - "libc", -] - [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itoa" @@ -779,15 +738,15 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" dependencies = [ "once_cell", "wasm-bindgen", @@ -801,9 +760,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" [[package]] name = "liblzma" @@ -864,9 +823,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" dependencies = [ "libc", ] @@ -878,17 +837,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] name = "mio" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" dependencies = [ "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -900,15 +860,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -917,9 +868,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "option-ext" @@ -996,9 +947,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -1040,9 +991,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags", ] @@ -1060,9 +1011,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1071,9 +1022,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rust-mcp-filesystem" @@ -1127,12 +1078,13 @@ dependencies = [ [[package]] name = "rust-mcp-sdk" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961ec01d0bedecf488388e6b1cf04170f9badab4927061c6592ffa385c02c6c9" +checksum = "98db95d9bb76c17fb263b6ddb63105de52223f5521878ba218b3c1e59408f5a7" dependencies = [ "async-trait", "base64", + "bytes", "futures", "rust-mcp-macros", "rust-mcp-schema", @@ -1147,9 +1099,9 @@ dependencies = [ [[package]] name = "rust-mcp-transport" -version = "0.6.0" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35feabc5e4667019dc262178724c94cbced6f43959af15e214b52f79243f55ed" +checksum = "b9be7b63ad5155c134856e1ffdb4bc1df00324eb4f23c6f314e6a6c03606a4a4" dependencies = [ "async-trait", "bytes", @@ -1163,12 +1115,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustix" version = "1.1.2" @@ -1179,7 +1125,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1278,6 +1224,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + [[package]] name = "similar" version = "2.7.0" @@ -1298,12 +1250,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1314,9 +1266,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "2.0.106" +version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ "proc-macro2", "quote", @@ -1330,10 +1282,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] @@ -1367,29 +1319,26 @@ dependencies = [ [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", "socket2", "tokio-macros", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -1460,9 +1409,9 @@ checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "utf8parse" @@ -1476,7 +1425,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] @@ -1503,15 +1452,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -1523,9 +1463,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" dependencies = [ "cfg-if", "once_cell", @@ -1534,25 +1474,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1560,22 +1486,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" dependencies = [ "unicode-ident", ] @@ -1586,14 +1512,14 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.1", + "windows-sys 0.61.2", ] [[package]] name = "windows-core" -version = "0.62.1" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6844ee5416b285084d3d3fffd743b925a6c9385455f64f6d4fa3031c4c2749a9" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", @@ -1604,9 +1530,9 @@ dependencies = [ [[package]] name = "windows-implement" -version = "0.60.1" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb307e42a74fb6de9bf3a02d9712678b22399c87e6fa869d6dfcd8c1b7754e0" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", @@ -1615,9 +1541,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.59.2" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0abd1ddbc6964ac14db11c7213d6532ef34bd9aa042c2e5935f59d7908b46a5" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", @@ -1626,183 +1552,110 @@ dependencies = [ [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.4", + "windows-targets", ] [[package]] name = "windows-sys" -version = "0.61.1" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[package]] name = "windows-targets" -version = "0.52.6" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d42b7b7f66d2a06854650af09cfdf8713e427a439c97ad65a6375318033ac4b" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "wit-bindgen" From 63ce9dc98398154bc8c672658e75e1688461dd8d Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 19:45:43 -0300 Subject: [PATCH 3/6] chore: sync --- src/fs_service/search/files.rs | 14 +++++++------- src/fs_service/search/tree.rs | 14 ++++++-------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/src/fs_service/search/files.rs b/src/fs_service/search/files.rs index b40b1b3..b83fc7b 100644 --- a/src/fs_service/search/files.rs +++ b/src/fs_service/search/files.rs @@ -158,13 +158,13 @@ impl FileSystemService { .filter(|e| e.file_type().is_file()); // Only files for entry in entries { - if let Ok(metadata) = entry.metadata() { - if let Some(path_str) = entry.path().to_str() { - size_map - .entry(metadata.len()) - .or_default() - .push(path_str.to_string()); - } + if let Ok(metadata) = entry.metadata() + && let Some(path_str) = entry.path().to_str() + { + size_map + .entry(metadata.len()) + .or_default() + .push(path_str.to_string()); } } diff --git a/src/fs_service/search/tree.rs b/src/fs_service/search/tree.rs index d0d29fa..e0c98d3 100644 --- a/src/fs_service/search/tree.rs +++ b/src/fs_service/search/tree.rs @@ -67,10 +67,10 @@ impl FileSystemService { *current_count += 1; // Check if we've exceeded max_files (if set) - if let Some(max) = max_files { - if *current_count > max { - continue; // Skip this entry but continue processing others - } + if let Some(max) = max_files + && *current_count > max + { + continue; // Skip this entry but continue processing others } let mut json_entry = json!({ @@ -191,10 +191,8 @@ impl FileSystemService { .filter_map(|e| e.ok()) .all(|e| !e.file_type().is_file() || is_system_metadata_file(e.file_name())); // Directory is empty if no files are found in it or subdirs, ".DS_Store" will be ignores on Mac - if is_empty { - if let Some(path_str) = entry.path().to_str() { - empty_dirs.push(path_str.to_string()); - } + if is_empty && let Some(path_str) = entry.path().to_str() { + empty_dirs.push(path_str.to_string()); } } From 51770fe2a987e84454186f149c194f62ea5a546e Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 19:47:50 -0300 Subject: [PATCH 4/6] chore: update rp config --- .release-config.json | 166 +++++++++++++++++++++---------------------- 1 file changed, 82 insertions(+), 84 deletions(-) diff --git a/.release-config.json b/.release-config.json index ee369d9..a5d98fc 100644 --- a/.release-config.json +++ b/.release-config.json @@ -1,86 +1,84 @@ { - "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "rust", - "release-as": "", - "include-component-in-tag": false, - "pull-request-title-pattern": "chore: release${component}", - "changelog-sections": [ - { - "type": "feature", - "section": "πŸš€ Features" - }, - { - "type": "feat", - "section": "πŸš€ Features" - }, - { - "type": "fix", - "section": "πŸ› Bug Fixes" - }, - { - "type": "perf", - "section": "⚑ Performance Improvements" - }, - { - "type": "revert", - "section": "◀️ Reverts" - }, - { - "type": "docs", - "section": "πŸ“š Documentation", - "hidden": false - }, - { - "type": "style", - "section": "🎨 Styles", - "hidden": true - }, - { - "type": "chore", - "section": "βš™οΈ Miscellaneous Chores", - "hidden": true - }, - { - "type": "refactor", - "section": "🚜 Code Refactoring", - "hidden": true - }, - { - "type": "test", - "section": "πŸ§ͺ Tests", - "hidden": true - }, - { - "type": "build", - "section": "πŸ› οΈ Build System", - "hidden": true - }, - { - "type": "ci", - "section": "πŸ₯ Continuous Integration", - "hidden": true - } - ], - "plugins": [ - "sentence-case" - ], - "pull-request-header": ":robot: Auto-generated release PR", - "packages": { - ".": { - "release-type": "rust", - "draft": false, - "prerelease": false, - "bump-minor-pre-major": true, - "bump-patch-for-minor-pre-major": true, - "include-component-in-tag": false, - "changelogPath": "CHANGELOG.md", - "extra-files": [ - "README.md", - "docs/_coverpage.md", - "docs/quickstart.md", - "docs/README.md", - "docs/guide/install.md" - ] - } + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "rust", + "release-as": "", + "include-component-in-tag": false, + "pull-request-title-pattern": "chore: release${component}", + "changelog-sections": [ + { + "type": "feature", + "section": "πŸš€ Features" + }, + { + "type": "feat", + "section": "πŸš€ Features" + }, + { + "type": "fix", + "section": "πŸ› Bug Fixes" + }, + { + "type": "perf", + "section": "⚑ Performance Improvements" + }, + { + "type": "revert", + "section": "◀️ Reverts" + }, + { + "type": "docs", + "section": "πŸ“š Documentation", + "hidden": false + }, + { + "type": "style", + "section": "🎨 Styles", + "hidden": true + }, + { + "type": "chore", + "section": "βš™οΈ Miscellaneous Chores", + "hidden": true + }, + { + "type": "refactor", + "section": "🚜 Code Refactoring", + "hidden": false + }, + { + "type": "test", + "section": "πŸ§ͺ Tests", + "hidden": true + }, + { + "type": "build", + "section": "πŸ› οΈ Build System", + "hidden": true + }, + { + "type": "ci", + "section": "πŸ₯ Continuous Integration", + "hidden": true } -} \ No newline at end of file + ], + "plugins": ["sentence-case"], + "pull-request-header": ":robot: Auto-generated release PR", + "packages": { + ".": { + "release-type": "rust", + "draft": false, + "prerelease": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "include-component-in-tag": false, + "changelogPath": "CHANGELOG.md", + "extra-files": [ + "README.md", + "docs/_coverpage.md", + "docs/quickstart.md", + "docs/README.md", + "docs/guide/install.md" + ] + } + } +} From fa49ef33961d0712b30e7ba2d55699ebffad5052 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 20:03:32 -0300 Subject: [PATCH 5/6] chore: update readme --- docs/README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/README.md b/docs/README.md index f5c7d4b..b036ed1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,34 @@ This project is a pure Rust rewrite of the JavaScript-based **@modelcontextproto Refer to the [quickstart](quickstart.md) guide for installation and configuration instructions. +#### **Shell script** + + + +```sh +curl --proto '=https' --tlsv1.2 -LsSf https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/download/v0.3.6/rust-mcp-filesystem-installer.sh | sh +``` + +#### **PowerShell script** + +```sh +powershell -ExecutionPolicy Bypass -c "irm https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/download/v0.3.6/rust-mcp-filesystem-installer.ps1 | iex" +``` + + + +#### **Homebrew** + +```sh +brew install rust-mcp-stack/tap/rust-mcp-filesystem +``` + +#### **Download Binaries** + +https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/tag/v0.3.6 + + + ## Purpose This project aims to provide a reliable, secure, and feature-rich MCP server for filesystem management, reimagining the capabilities of **@modelcontextprotocol/server-filesystem** in a more performant and type-safe language. Whether you’re using this for file exploration, automation, or system integration, rust-mcp-filesystem offers a solid foundation. From c2db5de5e3fcb084d17ec0f8347c79bae3f6253a Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 31 Oct 2025 20:10:10 -0300 Subject: [PATCH 6/6] chore: update dist --- .github/workflows/release.yml | 12 +++++++++--- README.md | 10 ++++++++++ dist-workspace.toml | 6 ++++-- docs/README.md | 19 ++++++++++++++----- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 937023d..bda7e10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,6 @@ -# This file was autogenerated by dist: https://github.com/astral-sh/cargo-dist +# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist # -# Copyright 2025 Astral Software Inc. +# Copyright 2022-2024, axodotdev # SPDX-License-Identifier: MIT or Apache-2.0 # # CI that: @@ -58,12 +58,13 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install dist # we specify bash to get pipefail; it guards against the `curl` command # failing. otherwise `sh` won't catch that `curl` returned non-0 shell: bash - run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/cargo-dist/releases/download/v0.28.3/cargo-dist-installer.sh | sh" + run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.0/cargo-dist-installer.sh | sh" - name: Cache dist uses: actions/upload-artifact@v4 with: @@ -117,6 +118,7 @@ jobs: git config --global core.longpaths true - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install Rust non-interactively if not already installed if: ${{ matrix.container }} @@ -175,6 +177,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v4 @@ -224,6 +227,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive - name: Install cached dist uses: actions/download-artifact@v4 @@ -286,6 +290,7 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: true repository: "rust-mcp-stack/homebrew-tap" token: ${{ secrets.HOMEBREW_TAP_TOKEN }} # So we have access to the formula @@ -332,4 +337,5 @@ jobs: steps: - uses: actions/checkout@v4 with: + persist-credentials: false submodules: recursive diff --git a/README.md b/README.md index c7975f9..b1cfef9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ powershell -ExecutionPolicy Bypass -c "irm https://github.com/rust-mcp-stack/rus ```sh brew install rust-mcp-stack/tap/rust-mcp-filesystem ``` + + +- **NPM** + +```sh +npm i -g @rustmcp/rust-mcp-filesystem@latest +``` +> The npm package is provided for convenience. It runs the same underlying Rust binary but can be installed and used as a standard npm package. + + - **Docker** https://hub.docker.com/mcp/server/rust-mcp-filesystem diff --git a/dist-workspace.toml b/dist-workspace.toml index 95fe410..f833e44 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -6,11 +6,11 @@ members = ["cargo:."] # Path that installers should place binaries in install-path = "~/.rust-mcp-stack/bin" # The preferred dist version to use in CI (Cargo.toml SemVer syntax) -cargo-dist-version = "0.28.3" +cargo-dist-version = "0.30.0" # CI backends to support ci = "github" # The installers to generate for each app -installers = ["shell", "powershell", "homebrew", "msi"] +installers = ["shell", "powershell", "npm", "homebrew", "msi"] # Target platforms to build apps for (Rust target-triple syntax) targets = ["aarch64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"] # The archive format to use for non-windows builds (defaults .tar.xz) @@ -23,6 +23,8 @@ create-release = false tap = "rust-mcp-stack/homebrew-tap" # Publish jobs to run in CI publish-jobs = ["homebrew"] +# A namespace to use when publishing this package to the npm registry +npm-scope = "@rustmcp" [dist.github-custom-runners] global = "ubuntu-22.04" diff --git a/docs/README.md b/docs/README.md index b036ed1..65fcd62 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,7 +19,7 @@ This project is a pure Rust rewrite of the JavaScript-based **@modelcontextproto Refer to the [quickstart](quickstart.md) guide for installation and configuration instructions. -#### **Shell script** +##### **Shell script** @@ -27,21 +27,30 @@ Refer to the [quickstart](quickstart.md) guide for installation and configuratio curl --proto '=https' --tlsv1.2 -LsSf https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/download/v0.3.6/rust-mcp-filesystem-installer.sh | sh ``` -#### **PowerShell script** +##### **PowerShell script** ```sh powershell -ExecutionPolicy Bypass -c "irm https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/download/v0.3.6/rust-mcp-filesystem-installer.ps1 | iex" ``` +##### **Homebrew** +```sh +brew install rust-mcp-stack/tap/rust-mcp-filesystem +``` -#### **Homebrew** +##### **NPM** ```sh -brew install rust-mcp-stack/tap/rust-mcp-filesystem +npm i -g @rustmcp/rust-mcp-filesystem@latest ``` +> The npm package is provided for convenience. It runs the same underlying Rust binary but can be installed and used as a standard npm package. + +##### **Docker** + + https://hub.docker.com/mcp/server/rust-mcp-filesystem -#### **Download Binaries** +##### **Download Binaries** https://github.com/rust-mcp-stack/rust-mcp-filesystem/releases/tag/v0.3.6