Skip to content
156 changes: 156 additions & 0 deletions src/filesystem/__tests__/compare-directories.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "fs/promises";
import * as os from "os";
import * as path from "path";
import { compareDirectories, setAllowedDirectories } from "../lib.js";

describe("compareDirectories", () => {
const testDir1 = path.join(os.tmpdir(), "test-compare-1-" + Date.now());
const testDir2 = path.join(os.tmpdir(), "test-compare-2-" + Date.now());

beforeEach(async () => {
await fs.mkdir(testDir1, { recursive: true });
await fs.mkdir(testDir2, { recursive: true });
// Set allowed directories for validation
setAllowedDirectories([testDir1, testDir2]);
});

afterEach(async () => {
await fs.rm(testDir1, { recursive: true, force: true });
await fs.rm(testDir2, { recursive: true, force: true });
});

it("identifies files only in first directory", async () => {
await fs.writeFile(path.join(testDir1, "only1.txt"), "content1");
await fs.writeFile(path.join(testDir1, "common.txt"), "common");
await fs.writeFile(path.join(testDir2, "common.txt"), "common");

const result = await compareDirectories(testDir1, testDir2);

expect(result.onlyInDir1).toContain("only1.txt");
expect(result.onlyInDir1).not.toContain("common.txt");
expect(result.identical).toContain("common.txt");

Check failure on line 32 in src/filesystem/__tests__/compare-directories.test.ts

View workflow job for this annotation

GitHub Actions / Test filesystem

__tests__/compare-directories.test.ts > compareDirectories > identifies files only in first directory

AssertionError: expected [] to include 'common.txt' ❯ __tests__/compare-directories.test.ts:32:30
});

it("identifies files only in second directory", async () => {
await fs.writeFile(path.join(testDir1, "common.txt"), "common");
await fs.writeFile(path.join(testDir2, "only2.txt"), "content2");

const result = await compareDirectories(testDir1, testDir2);

expect(result.onlyInDir2).toContain("only2.txt");
expect(result.onlyInDir2).not.toContain("common.txt");
});

it("detects files with different content by size", async () => {
await fs.writeFile(path.join(testDir1, "diff.txt"), "content1");
await fs.writeFile(path.join(testDir2, "diff.txt"), "different content");

const result = await compareDirectories(testDir1, testDir2);

expect(result.differentContent).toHaveLength(1);
expect(result.differentContent[0].path).toBe("diff.txt");
expect(result.differentContent[0].dir1Size).not.toBe(result.differentContent[0].dir2Size);
});

it("identifies identical files by size and mtime", async () => {
await fs.writeFile(path.join(testDir1, "same.txt"), "identical content");
await fs.writeFile(path.join(testDir2, "same.txt"), "identical content");

const result = await compareDirectories(testDir1, testDir2);

expect(result.identical).toContain("same.txt");
expect(result.differentContent).toHaveLength(0);
});

it("handles empty directories", async () => {
const result = await compareDirectories(testDir1, testDir2);

expect(result.onlyInDir1).toHaveLength(0);
expect(result.onlyInDir2).toHaveLength(0);
expect(result.differentContent).toHaveLength(0);
expect(result.identical).toHaveLength(0);
});

it("compares nested directory structures", async () => {
await fs.mkdir(path.join(testDir1, "subdir"), { recursive: true });
await fs.mkdir(path.join(testDir2, "subdir"), { recursive: true });
await fs.writeFile(path.join(testDir1, "subdir", "nested.txt"), "nested");
await fs.writeFile(path.join(testDir2, "subdir", "nested.txt"), "nested");

const result = await compareDirectories(testDir1, testDir2);

expect(result.identical).toContain("subdir/nested.txt");

Check failure on line 83 in src/filesystem/__tests__/compare-directories.test.ts

View workflow job for this annotation

GitHub Actions / Test filesystem

__tests__/compare-directories.test.ts > compareDirectories > compares nested directory structures

AssertionError: expected [] to include 'subdir/nested.txt' ❯ __tests__/compare-directories.test.ts:83:30
});

describe("compareContent=true", () => {
it("detects different content with same size", async () => {
// Same size but different content
await fs.writeFile(path.join(testDir1, "same-size.txt"), "aaa");
await fs.writeFile(path.join(testDir2, "same-size.txt"), "bbb");

const result = await compareDirectories(testDir1, testDir2, true);

expect(result.differentContent).toHaveLength(1);
expect(result.differentContent[0].path).toBe("same-size.txt");
expect(result.identical).toHaveLength(0);
});

it("identifies same content despite different mtime", async () => {
// Write same content to both files
await fs.writeFile(path.join(testDir1, "same-content.txt"), "same data");
await fs.writeFile(path.join(testDir2, "same-content.txt"), "same data");

// Wait and modify one file to change mtime
await new Promise(resolve => setTimeout(resolve, 100));
await fs.writeFile(path.join(testDir1, "marker.txt"), "marker");

const result = await compareDirectories(testDir1, testDir2, true);

// With compareContent=true, content is what matters, not mtime
expect(result.identical).toContain("same-content.txt");
expect(result.differentContent).toHaveLength(0);
});

it("detects different content when sizes match", async () => {
// Create files with same size but different content
await fs.writeFile(path.join(testDir1, "binary.bin"), Buffer.from([0x01, 0x02, 0x03]));
await fs.writeFile(path.join(testDir2, "binary.bin"), Buffer.from([0x04, 0x05, 0x06]));

const result = await compareDirectories(testDir1, testDir2, true);

expect(result.differentContent).toHaveLength(1);
expect(result.differentContent[0].path).toBe("binary.bin");
expect(result.identical).toHaveLength(0);
});

it("identifies identical binary content", async () => {
const binaryData = Buffer.from([0x00, 0x01, 0x02, 0x03, 0xff]);
await fs.writeFile(path.join(testDir1, "identical.bin"), binaryData);
await fs.writeFile(path.join(testDir2, "identical.bin"), binaryData);

const result = await compareDirectories(testDir1, testDir2, true);

expect(result.identical).toContain("identical.bin");
expect(result.differentContent).toHaveLength(0);
});
});

describe("compareContent=false (default)", () => {
it("marks files with different mtime as different", async () => {
await fs.writeFile(path.join(testDir1, "diff-mtime.txt"), "same");
await fs.writeFile(path.join(testDir2, "diff-mtime.txt"), "same");

// Wait and modify one file to change mtime
await new Promise(resolve => setTimeout(resolve, 100));
await fs.writeFile(path.join(testDir1, "marker.txt"), "marker");

const result = await compareDirectories(testDir1, testDir2, false);

// With compareContent=false, different mtime means different files
expect(result.differentContent).toHaveLength(1);

Check failure on line 151 in src/filesystem/__tests__/compare-directories.test.ts

View workflow job for this annotation

GitHub Actions / Test filesystem

__tests__/compare-directories.test.ts > compareDirectories > compareContent=false (default) > marks files with different mtime as different

AssertionError: expected [] to have a length of 1 but got +0 - Expected + Received - 1 + 0 ❯ __tests__/compare-directories.test.ts:151:39
expect(result.differentContent[0].path).toBe("diff-mtime.txt");
expect(result.identical).toHaveLength(0);
});
});
});
57 changes: 57 additions & 0 deletions src/filesystem/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
tailFile,
headFile,
setAllowedDirectories,
compareDirectories,
} from './lib.js';

// Command line argument parsing
Expand Down Expand Up @@ -702,6 +703,62 @@ server.registerTool(
}
);

server.registerTool(
"compare_directories",
{
title: "Compare Directories",
description:
"Compare two directories and report differences. Returns files only in first dir, " +
"only in second dir, files with different content (size/mtime), and identical files. " +
"Useful for syncing and finding changes between directory versions.",
inputSchema: {
dir1: z.string(),
dir2: z.string(),
compareContent: z.boolean().optional()
},
outputSchema: { content: z.string() },
annotations: { readOnlyHint: true }
},
async (args: { dir1: string; dir2: string; compareContent?: boolean }) => {
const validDir1 = await validatePath(args.dir1);
const validDir2 = await validatePath(args.dir2);
const result = await compareDirectories(validDir1, validDir2, args.compareContent);

const lines: string[] = [];
lines.push(`Comparison: ${args.dir1} vs ${args.dir2}`);
lines.push("");

if (result.onlyInDir1.length > 0) {
lines.push(`Only in ${args.dir1} (${result.onlyInDir1.length}):`);
result.onlyInDir1.forEach(f => lines.push(` - ${f}`));
lines.push("");
}

if (result.onlyInDir2.length > 0) {
lines.push(`Only in ${args.dir2} (${result.onlyInDir2.length}):`);
result.onlyInDir2.forEach(f => lines.push(` - ${f}`));
lines.push("");
}

if (result.differentContent.length > 0) {
lines.push(`Different content (${result.differentContent.length}):`);
result.differentContent.forEach(f => {
lines.push(` - ${f.path}`);
lines.push(` Size: ${f.dir1Size} vs ${f.dir2Size}`);
});
lines.push("");
}

lines.push(`Identical files: ${result.identical.length}`);

const text = lines.join("\n");
return {
content: [{ type: "text" as const, text }],
structuredContent: { result }
};
}
);

// Updates allowed directories based on MCP client roots
async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) {
const validatedRootDirs = await getValidRootDirectories(requestedRoots);
Expand Down
122 changes: 122 additions & 0 deletions src/filesystem/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,125 @@ export async function searchFilesWithValidation(
await search(rootPath);
return results;
}

// Helper function to compare file contents byte-by-byte
async function compareFileContents(file1: string, file2: string): Promise<boolean> {
try {
const [content1, content2] = await Promise.all([
fs.readFile(file1),
fs.readFile(file2)
]);

if (content1.length !== content2.length) {
return false;
}

return content1.equals(content2);
} catch {
return false;
}
}

export interface DirectoryComparisonResult {
onlyInDir1: string[];
onlyInDir2: string[];
differentContent: Array<{
path: string;
dir1Size: number;
dir2Size: number;
dir1Mtime: number;
dir2Mtime: number;
}>;
identical: string[];
}

export async function compareDirectories(
dir1: string,
dir2: string,
compareContent: boolean = false
): Promise<DirectoryComparisonResult> {
const dir1Files = await searchFilesWithValidation(dir1, "**/*", [dir1]);
const dir2Files = await searchFilesWithValidation(dir2, "**/*", [dir2]);

const dir1Set = new Set(dir1Files.map((f: string) => path.relative(dir1, f)));
const dir2Set = new Set(dir2Files.map((f: string) => path.relative(dir2, f)));

const onlyInDir1: string[] = [];
const onlyInDir2: string[] = [];
const differentContent: DirectoryComparisonResult["differentContent"] = [];
const identical: string[] = [];

// Files only in dir1
for (const file of dir1Files) {
const relPath = path.relative(dir1, file);
if (!dir2Set.has(relPath)) {
onlyInDir1.push(relPath);
}
}

// Files only in dir2
for (const file of dir2Files) {
const relPath = path.relative(dir2, file);
if (!dir1Set.has(relPath)) {
onlyInDir2.push(relPath);
}
}

// Compare common files
for (const relPath of dir1Set) {
if (dir2Set.has(relPath)) {
const file1 = path.join(dir1, relPath);
const file2 = path.join(dir2, relPath);

const [stat1, stat2] = await Promise.all([
fs.stat(file1),
fs.stat(file2)
]);

// If sizes differ, files are definitely different
if (stat1.size !== stat2.size) {
differentContent.push({
path: relPath,
dir1Size: stat1.size,
dir2Size: stat2.size,
dir1Mtime: stat1.mtimeMs,
dir2Mtime: stat2.mtimeMs
});
} else if (compareContent) {
// Sizes are equal, compare actual content
const contentsEqual = await compareFileContents(file1, file2);
if (contentsEqual) {
identical.push(relPath);
} else {
differentContent.push({
path: relPath,
dir1Size: stat1.size,
dir2Size: stat2.size,
dir1Mtime: stat1.mtimeMs,
dir2Mtime: stat2.mtimeMs
});
}
} else {
// compareContent is false, use mtime as indicator
if (stat1.mtimeMs !== stat2.mtimeMs) {
differentContent.push({
path: relPath,
dir1Size: stat1.size,
dir2Size: stat2.size,
dir1Mtime: stat1.mtimeMs,
dir2Mtime: stat2.mtimeMs
});
} else {
identical.push(relPath);
}
}
}
}

return {
onlyInDir1,
onlyInDir2,
differentContent,
identical
};
}
Loading