Skip to content

Commit a67829b

Browse files
author
Abderraouf Belalia
committed
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]
1 parent 54904fd commit a67829b

File tree

9 files changed

+449
-24
lines changed

9 files changed

+449
-24
lines changed

.claude/settings.local.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"WebFetch(domain:github.com)",
5+
"Bash(cargo make:*)"
6+
],
7+
"deny": [],
8+
"ask": []
9+
}
10+
}

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ infer = "0.19.0"
4141
rayon = "1.11.0"
4242
sha2 = "0.10.9"
4343
glob-match = "0.2"
44+
hex = "0.4"
4445

4546
[dev-dependencies]
4647
tempfile = "3.2"

docs/capabilities.md

Lines changed: 37 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
<!-- mcp-discovery-render -->
44
## rust-mcp-filesystem 0.3.6
5-
| 🟢 Tools (24) | <span style="opacity:0.6">🔴 Prompts</span> | <span style="opacity:0.6">🔴 Resources</span> | <span style="opacity:0.6">🔴 Logging</span> | <span style="opacity:0.6">🔴 Completions</span> | <span style="opacity:0.6">🔴 Experimental</span> |
5+
| 🟢 Tools (25) | <span style="opacity:0.6">🔴 Prompts</span> | <span style="opacity:0.6">🔴 Resources</span> | <span style="opacity:0.6">🔴 Logging</span> | <span style="opacity:0.6">🔴 Completions</span> | <span style="opacity:0.6">🔴 Experimental</span> |
66
| --- | --- | --- | --- | --- | --- |
77

8-
## 🛠️ Tools (24)
8+
## 🛠️ Tools (25)
99

1010
<table style="text-align: left;">
1111
<thead>
@@ -44,6 +44,20 @@
4444
</tr>
4545
<tr>
4646
<td>3.</td>
47+
<td>
48+
<code><b>diff_files</b></code>
49+
</td>
50+
<td>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.</td>
51+
<td>
52+
<ul>
53+
<li> <code>maxFileSizeBytes</code> : integer<br /></li>
54+
<li> <code>path1</code> : string<br /></li>
55+
<li> <code>path2</code> : string<br /></li>
56+
</ul>
57+
</td>
58+
</tr>
59+
<tr>
60+
<td>4.</td>
4761
<td>
4862
<code><b>directory_tree</b></code>
4963
</td>
@@ -56,7 +70,7 @@
5670
</td>
5771
</tr>
5872
<tr>
59-
<td>4.</td>
73+
<td>5.</td>
6074
<td>
6175
<code><b>edit_file</b></code>
6276
</td>
@@ -70,7 +84,7 @@
7084
</td>
7185
</tr>
7286
<tr>
73-
<td>5.</td>
87+
<td>6.</td>
7488
<td>
7589
<code><b>find_duplicate_files</b></code>
7690
</td>
@@ -87,7 +101,7 @@
87101
</td>
88102
</tr>
89103
<tr>
90-
<td>6.</td>
104+
<td>7.</td>
91105
<td>
92106
<code><b>find_empty_directories</b></code>
93107
</td>
@@ -101,7 +115,7 @@
101115
</td>
102116
</tr>
103117
<tr>
104-
<td>7.</td>
118+
<td>8.</td>
105119
<td>
106120
<code><b>get_file_info</b></code>
107121
</td>
@@ -113,7 +127,7 @@
113127
</td>
114128
</tr>
115129
<tr>
116-
<td>8.</td>
130+
<td>9.</td>
117131
<td>
118132
<code><b>head_file</b></code>
119133
</td>
@@ -126,7 +140,7 @@
126140
</td>
127141
</tr>
128142
<tr>
129-
<td>9.</td>
143+
<td>10.</td>
130144
<td>
131145
<code><b>list_allowed_directories</b></code>
132146
</td>
@@ -137,7 +151,7 @@
137151
</td>
138152
</tr>
139153
<tr>
140-
<td>10.</td>
154+
<td>11.</td>
141155
<td>
142156
<code><b>list_directory</b></code>
143157
</td>
@@ -149,7 +163,7 @@
149163
</td>
150164
</tr>
151165
<tr>
152-
<td>11.</td>
166+
<td>12.</td>
153167
<td>
154168
<code><b>list_directory_with_sizes</b></code>
155169
</td>
@@ -161,7 +175,7 @@
161175
</td>
162176
</tr>
163177
<tr>
164-
<td>12.</td>
178+
<td>13.</td>
165179
<td>
166180
<code><b>move_file</b></code>
167181
</td>
@@ -174,7 +188,7 @@
174188
</td>
175189
</tr>
176190
<tr>
177-
<td>13.</td>
191+
<td>14.</td>
178192
<td>
179193
<code><b>read_file_lines</b></code>
180194
</td>
@@ -188,7 +202,7 @@
188202
</td>
189203
</tr>
190204
<tr>
191-
<td>14.</td>
205+
<td>15.</td>
192206
<td>
193207
<code><b>read_media_file</b></code>
194208
</td>
@@ -201,7 +215,7 @@
201215
</td>
202216
</tr>
203217
<tr>
204-
<td>15.</td>
218+
<td>16.</td>
205219
<td>
206220
<code><b>read_multiple_media_files</b></code>
207221
</td>
@@ -214,7 +228,7 @@
214228
</td>
215229
</tr>
216230
<tr>
217-
<td>16.</td>
231+
<td>17.</td>
218232
<td>
219233
<code><b>read_multiple_text_files</b></code>
220234
</td>
@@ -226,7 +240,7 @@
226240
</td>
227241
</tr>
228242
<tr>
229-
<td>17.</td>
243+
<td>18.</td>
230244
<td>
231245
<code><b>read_text_file</b></code>
232246
</td>
@@ -239,7 +253,7 @@
239253
</td>
240254
</tr>
241255
<tr>
242-
<td>18.</td>
256+
<td>19.</td>
243257
<td>
244258
<code><b>search_files</b></code>
245259
</td>
@@ -255,7 +269,7 @@
255269
</td>
256270
</tr>
257271
<tr>
258-
<td>19.</td>
272+
<td>20.</td>
259273
<td>
260274
<code><b>search_files_content</b></code>
261275
</td>
@@ -273,7 +287,7 @@
273287
</td>
274288
</tr>
275289
<tr>
276-
<td>20.</td>
290+
<td>21.</td>
277291
<td>
278292
<code><b>tail_file</b></code>
279293
</td>
@@ -286,7 +300,7 @@
286300
</td>
287301
</tr>
288302
<tr>
289-
<td>21.</td>
303+
<td>22.</td>
290304
<td>
291305
<code><b>unzip_file</b></code>
292306
</td>
@@ -299,7 +313,7 @@
299313
</td>
300314
</tr>
301315
<tr>
302-
<td>22.</td>
316+
<td>23.</td>
303317
<td>
304318
<code><b>write_file</b></code>
305319
</td>
@@ -312,7 +326,7 @@
312326
</td>
313327
</tr>
314328
<tr>
315-
<td>23.</td>
329+
<td>24.</td>
316330
<td>
317331
<code><b>zip_directory</b></code>
318332
</td>
@@ -326,7 +340,7 @@
326340
</td>
327341
</tr>
328342
<tr>
329-
<td>24.</td>
343+
<td>25.</td>
330344
<td>
331345
<code><b>zip_files</b></code>
332346
</td>

src/fs_service.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,131 @@ impl FileSystemService {
596596
}
597597
}
598598

599+
pub async fn diff_files(
600+
&self,
601+
path1: &Path,
602+
path2: &Path,
603+
max_bytes: Option<u64>,
604+
) -> ServiceResult<String> {
605+
const DEFAULT_MAX_SIZE: u64 = 10 * 1024 * 1024; // 10MB
606+
let max_file_size = max_bytes.unwrap_or(DEFAULT_MAX_SIZE) as usize;
607+
608+
// Validate both paths
609+
let allowed_directories = self.allowed_directories().await;
610+
let valid_path1 = self.validate_path(path1, allowed_directories.clone())?;
611+
let valid_path2 = self.validate_path(path2, allowed_directories)?;
612+
613+
// Validate file sizes
614+
self.validate_file_size(&valid_path1, None, Some(max_file_size))
615+
.await?;
616+
self.validate_file_size(&valid_path2, None, Some(max_file_size))
617+
.await?;
618+
619+
// Check if files are binary using infer crate or by checking for null bytes
620+
let mut is_binary1 = infer::get_from_path(&valid_path1)
621+
.ok()
622+
.flatten()
623+
.map(|kind| !kind.mime_type().starts_with("text/"))
624+
.unwrap_or(false);
625+
626+
let mut is_binary2 = infer::get_from_path(&valid_path2)
627+
.ok()
628+
.flatten()
629+
.map(|kind| !kind.mime_type().starts_with("text/"))
630+
.unwrap_or(false);
631+
632+
// If infer didn't detect binary, check for null bytes
633+
if !is_binary1 {
634+
let mut buffer = vec![0u8; 8192];
635+
if let Ok(mut file) = File::open(&valid_path1).await {
636+
if let Ok(n) = file.read(&mut buffer).await {
637+
is_binary1 = buffer[..n].contains(&0);
638+
}
639+
}
640+
}
641+
642+
if !is_binary2 {
643+
let mut buffer = vec![0u8; 8192];
644+
if let Ok(mut file) = File::open(&valid_path2).await {
645+
if let Ok(n) = file.read(&mut buffer).await {
646+
is_binary2 = buffer[..n].contains(&0);
647+
}
648+
}
649+
}
650+
651+
if is_binary1 || is_binary2 {
652+
// Binary file comparison using SHA-256 hash
653+
let hash1 = self.calculate_file_hash(&valid_path1).await?;
654+
let hash2 = self.calculate_file_hash(&valid_path2).await?;
655+
656+
if hash1 == hash2 {
657+
Ok(format!(
658+
"Binary files are identical.\n\nSHA-256: {}",
659+
hex::encode(&hash1)
660+
))
661+
} else {
662+
Ok(format!(
663+
"Binary files differ.\n\nFile 1 ({}): {}\nFile 2 ({}): {}",
664+
path1.display(),
665+
hex::encode(&hash1),
666+
path2.display(),
667+
hex::encode(&hash2)
668+
))
669+
}
670+
} else {
671+
// Text file comparison using unified diff
672+
let content1 = tokio::fs::read_to_string(&valid_path1).await?;
673+
let content2 = tokio::fs::read_to_string(&valid_path2).await?;
674+
675+
// Check if files are identical
676+
if content1 == content2 {
677+
return Ok("Files are identical (no differences).".to_string());
678+
}
679+
680+
// Normalize line endings for consistent diff
681+
let normalized1 = normalize_line_endings(&content1);
682+
let normalized2 = normalize_line_endings(&content2);
683+
684+
// Generate unified diff
685+
let diff = TextDiff::from_lines(&normalized1, &normalized2);
686+
687+
let patch = diff
688+
.unified_diff()
689+
.header(
690+
&format!("{}", path1.display()),
691+
&format!("{}", path2.display()),
692+
)
693+
.context_radius(3)
694+
.to_string();
695+
696+
// Wrap in markdown code block with dynamic backtick count
697+
let backtick_count = std::cmp::max(
698+
content1.matches("```").count(),
699+
content2.matches("```").count(),
700+
) + 3;
701+
let backticks = "`".repeat(backtick_count);
702+
703+
Ok(format!("{backticks}diff\n{patch}{backticks}"))
704+
}
705+
}
706+
707+
async fn calculate_file_hash(&self, path: &Path) -> ServiceResult<Vec<u8>> {
708+
let file = File::open(path).await?;
709+
let mut reader = BufReader::new(file);
710+
let mut hasher = Sha256::new();
711+
let mut buffer = vec![0u8; 8192]; // 8KB chunks
712+
713+
loop {
714+
let bytes_read = reader.read(&mut buffer).await?;
715+
if bytes_read == 0 {
716+
break;
717+
}
718+
hasher.update(&buffer[..bytes_read]);
719+
}
720+
721+
Ok(hasher.finalize().to_vec())
722+
}
723+
599724
pub async fn create_directory(&self, file_path: &Path) -> ServiceResult<()> {
600725
let allowed_directories = self.allowed_directories().await;
601726
let valid_path = self.validate_path(file_path, allowed_directories)?;

0 commit comments

Comments
 (0)