From 0f5f44fa394559a92384f773183293e1c8c5a94c Mon Sep 17 00:00:00 2001 From: Abderraouf Belalia Date: Wed, 29 Oct 2025 02:34:41 +0100 Subject: [PATCH 1/4] feat: add optional line numbering to read_text_file tool Implements #60 - Add line numbering flag for read_text_file Changes: - Added `with_line_numbers` optional parameter to ReadTextFile struct - Updated read_text_file service method to format output with line numbers - Line numbers are right-aligned (6 digits) with pipe separator format - Uses 1-based indexing for line numbers - Maintains backward compatibility (defaults to false) - Added comprehensive unit tests for various scenarios - Updated CHANGELOG.md with feature description This feature enables AI agents to obtain file content with line numbers in a single tool invocation, improving efficiency for code modification tasks that require precise line-based targeting. [agent commit] --- CHANGELOG.md | 10 ++++ src/fs_service.rs | 14 ++++- src/tools/read_multiple_text_files.rs | 2 +- src/tools/read_text_file.rs | 13 ++++- tests/test_fs_service.rs | 78 ++++++++++++++++++++++++++- 5 files changed, 111 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d240a27..f974777 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [Unreleased] + +### 🚀 Features + +* Add optional line numbering to read_text_file tool ([#60](https://github.com/rust-mcp-stack/rust-mcp-filesystem/issues/60)) + - Added `with_line_numbers` optional parameter to `read_text_file` tool + - When enabled, prefixes each line with right-aligned line numbers and pipe separator + - Useful for AI agents that need to target specific lines for code patches + - Maintains backward compatibility with existing usage + ## [0.3.6](https://github.com/rust-mcp-stack/rust-mcp-filesystem/compare/v0.3.5...v0.3.6) (2025-10-15) diff --git a/src/fs_service.rs b/src/fs_service.rs index 8797ae7..153dd52 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -575,11 +575,21 @@ impl FileSystemService { Ok(base64_string) } - pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult { + pub async fn read_text_file(&self, file_path: &Path, with_line_numbers: bool) -> 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) + + if with_line_numbers { + Ok(content + .lines() + .enumerate() + .map(|(i, line)| format!("{:>6} | {}", i + 1, line)) + .collect::>() + .join("\n")) + } else { + Ok(content) + } } pub async fn create_directory(&self, file_path: &Path) -> ServiceResult<()> { diff --git a/src/tools/read_multiple_text_files.rs b/src/tools/read_multiple_text_files.rs index 91923e4..efee983 100644 --- a/src/tools/read_multiple_text_files.rs +++ b/src/tools/read_multiple_text_files.rs @@ -35,7 +35,7 @@ impl ReadMultipleTextFiles { .map(|path| async move { { let content = context - .read_text_file(Path::new(&path)) + .read_text_file(Path::new(&path), false) .await .map_err(CallToolError::new); diff --git a/src/tools/read_text_file.rs b/src/tools/read_text_file.rs index 3872625..1ea83d7 100644 --- a/src/tools/read_text_file.rs +++ b/src/tools/read_text_file.rs @@ -12,7 +12,8 @@ use crate::fs_service::FileSystemService; description = concat!("Read the complete contents of a text file from the file system as text. ", "Handles various text encodings and provides detailed error messages if the ", "file cannot be read. Use this tool when you need to examine the contents of ", - "a single file. Only works within allowed directories."), + "a single file. Optionally include line numbers for precise code targeting. ", + "Only works within allowed directories."), destructive_hint = false, idempotent_hint = false, open_world_hint = false, @@ -22,6 +23,11 @@ use crate::fs_service::FileSystemService; pub struct ReadTextFile { /// The path of the file to read. pub path: String, + /// Optional: Include line numbers in output (default: false). + /// When enabled, each line is prefixed with its line number (1-based). + /// Useful for AI agents that need to target specific lines for code patches. + #[serde(default)] + pub with_line_numbers: Option, } impl ReadTextFile { @@ -30,7 +36,10 @@ impl ReadTextFile { context: &FileSystemService, ) -> std::result::Result { let content = context - .read_text_file(Path::new(¶ms.path)) + .read_text_file( + Path::new(¶ms.path), + params.with_line_numbers.unwrap_or(false), + ) .await .map_err(CallToolError::new)?; diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 9bd7bd3..b014909 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -230,10 +230,86 @@ async fn test_unzip_file_non_existent() { async fn test_read_file() { let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content"); - let content = service.read_text_file(&file_path).await.unwrap(); + let content = service.read_text_file(&file_path, false).await.unwrap(); assert_eq!(content, "content"); } +#[tokio::test] +async fn test_read_text_file_with_line_numbers() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3" + ); + let content = service.read_text_file(&file_path, true).await.unwrap(); + assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3"); +} + +#[tokio::test] +async fn test_read_text_file_without_line_numbers() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "test.txt", + "line1\nline2\nline3" + ); + let content = service.read_text_file(&file_path, false).await.unwrap(); + assert_eq!(content, "line1\nline2\nline3"); +} + +#[tokio::test] +async fn test_read_text_file_with_line_numbers_empty_file() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "empty.txt", ""); + let content = service.read_text_file(&file_path, true).await.unwrap(); + assert_eq!(content, ""); +} + +#[tokio::test] +async fn test_read_text_file_with_line_numbers_single_line() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "single.txt", "single line"); + let content = service.read_text_file(&file_path, true).await.unwrap(); + assert_eq!(content, " 1 | single line"); +} + +#[tokio::test] +async fn test_read_text_file_with_line_numbers_no_trailing_newline() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "no_newline.txt", + "line1\nline2" + ); + let content = service.read_text_file(&file_path, true).await.unwrap(); + assert_eq!(content, " 1 | line1\n 2 | line2"); +} + +#[tokio::test] +async fn test_read_text_file_with_line_numbers_large_file() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + // Create a file with more than 999 lines to test padding + let mut lines = Vec::new(); + for i in 1..=1000 { + lines.push(format!("line{}", i)); + } + let file_content = lines.join("\n"); + let file_path = create_temp_file( + temp_dir.join("dir1").as_path(), + "large.txt", + &file_content + ); + let content = service.read_text_file(&file_path, true).await.unwrap(); + + // Check first line + assert!(content.starts_with(" 1 | line1\n")); + // Check line 999 + assert!(content.contains(" 999 | line999\n")); + // Check line 1000 (6 digits with right padding) + assert!(content.contains(" 1000 | line1000")); +} + #[tokio::test] async fn test_create_directory() { let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); From 58e163800406e4436aa1efe09423a1ad177806fe Mon Sep 17 00:00:00 2001 From: Abderraouf Belalia Date: Wed, 29 Oct 2025 03:02:09 +0100 Subject: [PATCH 2/4] style: fix formatting and clippy warnings [agent commit] --- src/fs_service.rs | 6 +++++- tests/test_fs_service.rs | 14 +++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/fs_service.rs b/src/fs_service.rs index 153dd52..eb4eee6 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -575,7 +575,11 @@ impl FileSystemService { Ok(base64_string) } - pub async fn read_text_file(&self, file_path: &Path, with_line_numbers: bool) -> ServiceResult { + pub async fn read_text_file( + &self, + file_path: &Path, + with_line_numbers: bool, + ) -> 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?; diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index b014909..b89e154 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -240,7 +240,7 @@ async fn test_read_text_file_with_line_numbers() { let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test.txt", - "line1\nline2\nline3" + "line1\nline2\nline3", ); let content = service.read_text_file(&file_path, true).await.unwrap(); assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3"); @@ -252,7 +252,7 @@ async fn test_read_text_file_without_line_numbers() { let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test.txt", - "line1\nline2\nline3" + "line1\nline2\nline3", ); let content = service.read_text_file(&file_path, false).await.unwrap(); assert_eq!(content, "line1\nline2\nline3"); @@ -280,7 +280,7 @@ async fn test_read_text_file_with_line_numbers_no_trailing_newline() { let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "no_newline.txt", - "line1\nline2" + "line1\nline2", ); let content = service.read_text_file(&file_path, true).await.unwrap(); assert_eq!(content, " 1 | line1\n 2 | line2"); @@ -292,14 +292,10 @@ async fn test_read_text_file_with_line_numbers_large_file() { // Create a file with more than 999 lines to test padding let mut lines = Vec::new(); for i in 1..=1000 { - lines.push(format!("line{}", i)); + lines.push(format!("line{i}")); } let file_content = lines.join("\n"); - let file_path = create_temp_file( - temp_dir.join("dir1").as_path(), - "large.txt", - &file_content - ); + let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "large.txt", &file_content); let content = service.read_text_file(&file_path, true).await.unwrap(); // Check first line From 54904fd9e19a88028d462705e5fd85e9bf6d3cf9 Mon Sep 17 00:00:00 2001 From: Abderraouf Belalia Date: Wed, 29 Oct 2025 03:17:05 +0100 Subject: [PATCH 3/4] docs: update capabilities with line numbering parameter [agent commit] --- docs/capabilities.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/capabilities.md b/docs/capabilities.md index 312cbd3..ae57cc2 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -230,10 +230,11 @@ read_text_file - Read the complete contents of a text file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Only works within allowed directories. + Read the complete contents of a text file from the file system as text. Handles various text encodings and provides detailed error messages if the file cannot be read. Use this tool when you need to examine the contents of a single file. Optionally include line numbers for precise code targeting. Only works within allowed directories.
  • path : string
  • +
  • with_line_numbers : boolean
From a67829b49ff36d830cacaf132b1a018d1fcc4b6b Mon Sep 17 00:00:00 2001 From: Abderraouf Belalia Date: Wed, 29 Oct 2025 15:25:51 +0100 Subject: [PATCH 4/4] feat: add diff_files tool for file comparison Implements a new diff_files tool that compares two files and returns: - Unified diff format for text files - SHA-256 hash comparison for binary files Features: - Configurable file size limit (default: 10MB) - Automatic binary detection via null bytes check - Read-only operation (no filesystem modification) - Comprehensive test coverage (8 test cases) Closes #62 [agent commit] --- .claude/settings.local.json | 10 ++ Cargo.lock | 7 ++ Cargo.toml | 1 + docs/capabilities.md | 60 +++++++---- src/fs_service.rs | 125 +++++++++++++++++++++ src/handler.rs | 1 + src/tools.rs | 4 + src/tools/diff_files.rs | 56 ++++++++++ tests/test_tools.rs | 209 +++++++++++++++++++++++++++++++++++- 9 files changed, 449 insertions(+), 24 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/tools/diff_files.rs diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0e1ac53 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "Bash(cargo make:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/Cargo.lock b/Cargo.lock index bea0a9a..4175f2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -717,6 +717,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -1088,6 +1094,7 @@ dependencies = [ "futures", "glob-match", "grep", + "hex", "infer", "rayon", "rust-mcp-sdk", diff --git a/Cargo.toml b/Cargo.toml index c894139..cc0774f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ infer = "0.19.0" rayon = "1.11.0" sha2 = "0.10.9" glob-match = "0.2" +hex = "0.4" [dev-dependencies] tempfile = "3.2" diff --git a/docs/capabilities.md b/docs/capabilities.md index ae57cc2..c61c044 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -2,10 +2,10 @@ ## rust-mcp-filesystem 0.3.6 -| 🟢 Tools (24) | 🔴 Prompts | 🔴 Resources | 🔴 Logging | 🔴 Completions | 🔴 Experimental | +| 🟢 Tools (25) | 🔴 Prompts | 🔴 Resources | 🔴 Logging | 🔴 Completions | 🔴 Experimental | | --- | --- | --- | --- | --- | --- | -## 🛠️ Tools (24) +## 🛠️ Tools (25) @@ -44,6 +44,20 @@ + + + + + + @@ -56,7 +70,7 @@ - + @@ -70,7 +84,7 @@ - + @@ -87,7 +101,7 @@ - + @@ -101,7 +115,7 @@ - + @@ -113,7 +127,7 @@ - + @@ -126,7 +140,7 @@ - + @@ -137,7 +151,7 @@ - + @@ -149,7 +163,7 @@ - + @@ -161,7 +175,7 @@ - + @@ -174,7 +188,7 @@ - + @@ -188,7 +202,7 @@ - + @@ -201,7 +215,7 @@ - + @@ -214,7 +228,7 @@ - + @@ -226,7 +240,7 @@ - + @@ -239,7 +253,7 @@ - + @@ -255,7 +269,7 @@ - + @@ -273,7 +287,7 @@ - + @@ -286,7 +300,7 @@ - + @@ -299,7 +313,7 @@ - + @@ -312,7 +326,7 @@ - + @@ -326,7 +340,7 @@ - + diff --git a/src/fs_service.rs b/src/fs_service.rs index eb4eee6..f62106d 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -596,6 +596,131 @@ impl FileSystemService { } } + pub async fn diff_files( + &self, + path1: &Path, + path2: &Path, + max_bytes: Option, + ) -> ServiceResult { + const DEFAULT_MAX_SIZE: u64 = 10 * 1024 * 1024; // 10MB + let max_file_size = max_bytes.unwrap_or(DEFAULT_MAX_SIZE) as usize; + + // Validate both paths + let allowed_directories = self.allowed_directories().await; + let valid_path1 = self.validate_path(path1, allowed_directories.clone())?; + let valid_path2 = self.validate_path(path2, allowed_directories)?; + + // Validate file sizes + self.validate_file_size(&valid_path1, None, Some(max_file_size)) + .await?; + self.validate_file_size(&valid_path2, None, Some(max_file_size)) + .await?; + + // Check if files are binary using infer crate or by checking for null bytes + let mut is_binary1 = infer::get_from_path(&valid_path1) + .ok() + .flatten() + .map(|kind| !kind.mime_type().starts_with("text/")) + .unwrap_or(false); + + let mut is_binary2 = infer::get_from_path(&valid_path2) + .ok() + .flatten() + .map(|kind| !kind.mime_type().starts_with("text/")) + .unwrap_or(false); + + // If infer didn't detect binary, check for null bytes + if !is_binary1 { + let mut buffer = vec![0u8; 8192]; + if let Ok(mut file) = File::open(&valid_path1).await { + if let Ok(n) = file.read(&mut buffer).await { + is_binary1 = buffer[..n].contains(&0); + } + } + } + + if !is_binary2 { + let mut buffer = vec![0u8; 8192]; + if let Ok(mut file) = File::open(&valid_path2).await { + if let Ok(n) = file.read(&mut buffer).await { + is_binary2 = buffer[..n].contains(&0); + } + } + } + + if is_binary1 || is_binary2 { + // Binary file comparison using SHA-256 hash + let hash1 = self.calculate_file_hash(&valid_path1).await?; + let hash2 = self.calculate_file_hash(&valid_path2).await?; + + if hash1 == hash2 { + Ok(format!( + "Binary files are identical.\n\nSHA-256: {}", + hex::encode(&hash1) + )) + } else { + Ok(format!( + "Binary files differ.\n\nFile 1 ({}): {}\nFile 2 ({}): {}", + path1.display(), + hex::encode(&hash1), + path2.display(), + hex::encode(&hash2) + )) + } + } else { + // Text file comparison using unified diff + let content1 = tokio::fs::read_to_string(&valid_path1).await?; + let content2 = tokio::fs::read_to_string(&valid_path2).await?; + + // Check if files are identical + if content1 == content2 { + return Ok("Files are identical (no differences).".to_string()); + } + + // Normalize line endings for consistent diff + let normalized1 = normalize_line_endings(&content1); + let normalized2 = normalize_line_endings(&content2); + + // Generate unified diff + let diff = TextDiff::from_lines(&normalized1, &normalized2); + + let patch = diff + .unified_diff() + .header( + &format!("{}", path1.display()), + &format!("{}", path2.display()), + ) + .context_radius(3) + .to_string(); + + // Wrap in markdown code block with dynamic backtick count + let backtick_count = std::cmp::max( + content1.matches("```").count(), + content2.matches("```").count(), + ) + 3; + let backticks = "`".repeat(backtick_count); + + Ok(format!("{backticks}diff\n{patch}{backticks}")) + } + } + + async fn calculate_file_hash(&self, path: &Path) -> ServiceResult> { + let file = File::open(path).await?; + let mut reader = 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?; + if bytes_read == 0 { + break; + } + hasher.update(&buffer[..bytes_read]); + } + + Ok(hasher.finalize().to_vec()) + } + 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)?; diff --git a/src/handler.rs b/src/handler.rs index 1855372..24a0fac 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -200,6 +200,7 @@ impl ServerHandler for FileSystemHandler { ReadMultipleMediaFiles, ReadTextFile, ReadMultipleTextFiles, + DiffFiles, WriteFile, EditFile, CreateDirectory, diff --git a/src/tools.rs b/src/tools.rs index d7c3c98..becd92b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1,5 +1,6 @@ mod calculate_directory_size; mod create_directory; +mod diff_files; mod directory_tree; mod edit_file; mod find_duplicate_files; @@ -23,6 +24,7 @@ mod zip_unzip; pub use calculate_directory_size::{CalculateDirectorySize, FileSizeOutputFormat}; pub use create_directory::CreateDirectory; +pub use diff_files::DiffFiles; pub use directory_tree::DirectoryTree; pub use edit_file::{EditFile, EditOperation}; pub use find_duplicate_files::FindDuplicateFiles; @@ -50,6 +52,7 @@ tool_box!( [ ReadTextFile, CreateDirectory, + DiffFiles, DirectoryTree, EditFile, GetFileInfo, @@ -88,6 +91,7 @@ impl FileSystemTools { | FileSystemTools::UnzipFile(_) | FileSystemTools::ZipDirectory(_) => true, FileSystemTools::ReadTextFile(_) + | FileSystemTools::DiffFiles(_) | FileSystemTools::DirectoryTree(_) | FileSystemTools::GetFileInfo(_) | FileSystemTools::ListAllowedDirectories(_) diff --git a/src/tools/diff_files.rs b/src/tools/diff_files.rs new file mode 100644 index 0000000..103d486 --- /dev/null +++ b/src/tools/diff_files.rs @@ -0,0 +1,56 @@ +use std::path::Path; + +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; +use rust_mcp_sdk::schema::TextContent; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; + +use crate::fs_service::FileSystemService; + +#[mcp_tool( + name = "diff_files", + title="Compare two files", + description = concat!("Generate a unified diff between two files. ", + "For text files, produces a standard unified diff format showing additions and deletions. ", + "For binary files, compares SHA-256 hashes and reports whether files are identical or different. ", + "Respects file size limits to prevent memory issues. ", + "Only works within allowed directories."), + destructive_hint = false, + idempotent_hint = true, + open_world_hint = false, + read_only_hint = true +)] +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +pub struct DiffFiles { + /// The path of the first file to compare. + pub path1: String, + /// The path of the second file to compare. + pub path2: String, + /// Optional: Maximum file size in bytes to process (default: 10485760 = 10MB). + /// Files exceeding this limit will return an error. + #[serde( + rename = "maxFileSizeBytes", + default, + skip_serializing_if = "std::option::Option::is_none" + )] + pub max_file_size_bytes: Option, +} + +impl DiffFiles { + pub async fn run_tool( + params: Self, + context: &FileSystemService, + ) -> std::result::Result { + let result = context + .diff_files( + Path::new(¶ms.path1), + Path::new(¶ms.path2), + params.max_file_size_bytes, + ) + .await + .map_err(CallToolError::new)?; + + Ok(CallToolResult::text_content(vec![TextContent::from( + result, + )])) + } +} diff --git a/tests/test_tools.rs b/tests/test_tools.rs index ef7b502..59adbb1 100644 --- a/tests/test_tools.rs +++ b/tests/test_tools.rs @@ -1,7 +1,7 @@ #[path = "common/common.rs"] pub mod common; -use common::setup_service; +use common::{create_temp_file, setup_service}; use rust_mcp_filesystem::tools::*; use rust_mcp_sdk::schema::{ContentBlock, schema_utils::CallToolError}; use std::{collections::HashSet, fs}; @@ -165,5 +165,212 @@ async fn ensure_tools_duplication() { assert_eq!(duplicate_descriptions.join(","), ""); } +#[tokio::test] +async fn test_diff_files_identical_text_files() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", "Hello\nWorld\n"); + let file2 = create_temp_file(&temp_dir.join("dir1"), "file2.txt", "Hello\nWorld\n"); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + + assert_eq!(call_result.content.len(), 1); + match call_result.content.first().unwrap() { + ContentBlock::TextContent(text_content) => { + assert!(text_content.text.contains("Files are identical")); + } + _ => panic!("Expected TextContent result"), + } +} + +#[tokio::test] +async fn test_diff_files_different_text_files() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", "Hello\nWorld\n"); + let file2 = create_temp_file(&temp_dir.join("dir1"), "file2.txt", "Hello\nRust\n"); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + + assert_eq!(call_result.content.len(), 1); + match call_result.content.first().unwrap() { + ContentBlock::TextContent(text_content) => { + assert!(text_content.text.contains("diff")); + assert!(text_content.text.contains("-World") || text_content.text.contains("+Rust")); + } + _ => panic!("Expected TextContent result"), + } +} + +#[tokio::test] +async fn test_diff_files_binary_identical() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + + // Create two identical binary files + let binary_data = vec![0u8, 1, 2, 3, 255, 254]; + let file1 = temp_dir.join("dir1").join("file1.bin"); + let file2 = temp_dir.join("dir1").join("file2.bin"); + + fs::write(&file1, &binary_data).unwrap(); + fs::write(&file2, &binary_data).unwrap(); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + + assert_eq!(call_result.content.len(), 1); + match call_result.content.first().unwrap() { + ContentBlock::TextContent(text_content) => { + assert!(text_content.text.contains("Binary files are identical")); + assert!(text_content.text.contains("SHA-256")); + } + _ => panic!("Expected TextContent result"), + } +} + +#[tokio::test] +async fn test_diff_files_binary_different() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + + // Create two different binary files + let binary_data1 = vec![0u8, 1, 2, 3, 255, 254]; + let binary_data2 = vec![0u8, 1, 2, 3, 255, 253]; // Last byte different + let file1 = temp_dir.join("dir1").join("file1.bin"); + let file2 = temp_dir.join("dir1").join("file2.bin"); + + fs::write(&file1, &binary_data1).unwrap(); + fs::write(&file2, &binary_data2).unwrap(); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + + assert_eq!(call_result.content.len(), 1); + match call_result.content.first().unwrap() { + ContentBlock::TextContent(text_content) => { + assert!(text_content.text.contains("Binary files differ")); + assert!(text_content.text.contains("SHA-256") || text_content.text.contains("File 1")); + } + _ => panic!("Expected TextContent result"), + } +} + +#[tokio::test] +async fn test_diff_files_outside_allowed_directory() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + + // Create files: one in allowed dir, one outside + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", "Hello\n"); + + // Create dir2 which is not allowed + fs::create_dir_all(temp_dir.join("dir2")).unwrap(); + let file2 = create_temp_file(&temp_dir.join("dir2"), "file2.txt", "World\n"); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CallToolError { .. })); +} + +#[tokio::test] +async fn test_diff_files_nonexistent_file() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", "Hello\n"); + let file2 = temp_dir.join("dir1").join("nonexistent.txt"); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CallToolError { .. })); +} + +#[tokio::test] +async fn test_diff_files_exceeds_size_limit() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + + // Create a file larger than the limit + let large_content = "x".repeat(1000); + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", &large_content); + let file2 = create_temp_file(&temp_dir.join("dir1"), "file2.txt", &large_content); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: Some(500), // Set limit to 500 bytes + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CallToolError { .. })); +} + +#[tokio::test] +async fn test_diff_files_multiline_changes() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let content1 = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"; + let content2 = "Line 1\nLine 2 modified\nLine 3\nLine 4 changed\nLine 5\n"; + let file1 = create_temp_file(&temp_dir.join("dir1"), "file1.txt", content1); + let file2 = create_temp_file(&temp_dir.join("dir1"), "file2.txt", content2); + + let params = DiffFiles { + path1: file1.to_str().unwrap().to_string(), + path2: file2.to_str().unwrap().to_string(), + max_file_size_bytes: None, + }; + + let result = DiffFiles::run_tool(params, &service).await; + assert!(result.is_ok()); + let call_result = result.unwrap(); + + assert_eq!(call_result.content.len(), 1); + match call_result.content.first().unwrap() { + ContentBlock::TextContent(text_content) => { + assert!(text_content.text.contains("diff")); + // Should show both changes + assert!(text_content.text.contains("Line 2") || text_content.text.contains("Line 4")); + } + _ => panic!("Expected TextContent result"), + } +} + #[tokio::test] async fn adhoc() {}
3. + diff_files + Generate a unified diff between two files. For text files, produces a standard unified diff format showing additions and deletions. For binary files, compares SHA-256 hashes and reports whether files are identical or different. Respects file size limits to prevent memory issues. Only works within allowed directories. +
    +
  • maxFileSizeBytes : integer
  • +
  • path1 : string
  • +
  • path2 : string
  • +
+
4. directory_tree
4.5. edit_file
5.6. find_duplicate_files
6.7. find_empty_directories
7.8. get_file_info
8.9. head_file
9.10. list_allowed_directories
10.11. list_directory
11.12. list_directory_with_sizes
12.13. move_file
13.14. read_file_lines
14.15. read_media_file
15.16. read_multiple_media_files
16.17. read_multiple_text_files
17.18. read_text_file
18.19. search_files
19.20. search_files_content
20.21. tail_file
21.22. unzip_file
22.23. write_file
23.24. zip_directory
24.25. zip_files