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/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/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 312cbd3..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 @@
| 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
|
@@ -56,7 +70,7 @@
- | 4. |
+ 5. |
edit_file
|
@@ -70,7 +84,7 @@
- | 5. |
+ 6. |
find_duplicate_files
|
@@ -87,7 +101,7 @@
- | 6. |
+ 7. |
find_empty_directories
|
@@ -101,7 +115,7 @@
- | 7. |
+ 8. |
get_file_info
|
@@ -113,7 +127,7 @@
- | 8. |
+ 9. |
head_file
|
@@ -126,7 +140,7 @@
- | 9. |
+ 10. |
list_allowed_directories
|
@@ -137,7 +151,7 @@
- | 10. |
+ 11. |
list_directory
|
@@ -149,7 +163,7 @@
- | 11. |
+ 12. |
list_directory_with_sizes
|
@@ -161,7 +175,7 @@
- | 12. |
+ 13. |
move_file
|
@@ -174,7 +188,7 @@
- | 13. |
+ 14. |
read_file_lines
|
@@ -188,7 +202,7 @@
- | 14. |
+ 15. |
read_media_file
|
@@ -201,7 +215,7 @@
- | 15. |
+ 16. |
read_multiple_media_files
|
@@ -214,7 +228,7 @@
- | 16. |
+ 17. |
read_multiple_text_files
|
@@ -226,19 +240,20 @@
- | 17. |
+ 18. |
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
|
- | 18. |
+ 19. |
search_files
|
@@ -254,7 +269,7 @@
- | 19. |
+ 20. |
search_files_content
|
@@ -272,7 +287,7 @@
- | 20. |
+ 21. |
tail_file
|
@@ -285,7 +300,7 @@
- | 21. |
+ 22. |
unzip_file
|
@@ -298,7 +313,7 @@
- | 22. |
+ 23. |
write_file
|
@@ -311,7 +326,7 @@
- | 23. |
+ 24. |
zip_directory
|
@@ -325,7 +340,7 @@
- | 24. |
+ 25. |
zip_files
|
diff --git a/src/fs_service.rs b/src/fs_service.rs
index 8797ae7..f62106d 100644
--- a/src/fs_service.rs
+++ b/src/fs_service.rs
@@ -575,11 +575,150 @@ 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 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<()> {
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/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..b89e154 100644
--- a/tests/test_fs_service.rs
+++ b/tests/test_fs_service.rs
@@ -230,10 +230,82 @@ 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()]);
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() {}