Skip to content

Commit acf3c6d

Browse files
committed
refactor(filesystem): extract buildDirectoryTree to lib.ts
Move the buildTree closure out of the directory_tree handler and export it as buildDirectoryTree. Replaces the duplicated buildTreeForTesting in the test suite so tests now exercise the production function directly. No behavior change.
1 parent 4503e2d commit acf3c6d

3 files changed

Lines changed: 79 additions & 119 deletions

File tree

src/filesystem/__tests__/directory-tree.test.ts

Lines changed: 30 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,24 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import * as fs from 'fs/promises';
33
import * as path from 'path';
44
import * as os from 'os';
5+
import { buildDirectoryTree, setAllowedDirectories } from '../lib.js';
56

6-
// We need to test the buildTree function, but it's defined inside the request handler
7-
// So we'll extract the core logic into a testable function
8-
import { minimatch } from 'minimatch';
9-
10-
interface TreeEntry {
11-
name: string;
12-
type: 'file' | 'directory';
13-
children?: TreeEntry[];
14-
}
15-
16-
async function buildTreeForTesting(currentPath: string, rootPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
17-
const entries = await fs.readdir(currentPath, {withFileTypes: true});
18-
const result: TreeEntry[] = [];
19-
20-
for (const entry of entries) {
21-
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
22-
const shouldExclude = excludePatterns.some(pattern => {
23-
if (pattern.includes('*')) {
24-
return minimatch(relativePath, pattern, {dot: true});
25-
}
26-
// For files: match exact name or as part of path
27-
// For directories: match as directory path
28-
return minimatch(relativePath, pattern, {dot: true}) ||
29-
minimatch(relativePath, `**/${pattern}`, {dot: true}) ||
30-
minimatch(relativePath, `**/${pattern}/**`, {dot: true});
31-
});
32-
if (shouldExclude)
33-
continue;
34-
35-
const entryData: TreeEntry = {
36-
name: entry.name,
37-
type: entry.isDirectory() ? 'directory' : 'file'
38-
};
39-
40-
if (entry.isDirectory()) {
41-
const subPath = path.join(currentPath, entry.name);
42-
entryData.children = await buildTreeForTesting(subPath, rootPath, excludePatterns);
43-
}
44-
45-
result.push(entryData);
46-
}
47-
48-
return result;
49-
}
50-
51-
describe('buildTree exclude patterns', () => {
7+
describe('buildDirectoryTree exclude patterns', () => {
528
let testDir: string;
539

5410
beforeEach(async () => {
55-
testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-'));
56-
57-
// Create test directory structure
11+
// On macOS, os.tmpdir() returns /var/folders/... which symlinks to
12+
// /private/var/folders/..., and validatePath compares against the
13+
// resolved real path. Resolve once up front so allowedDirectories
14+
// matches what the recursion sees.
15+
testDir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-')));
16+
setAllowedDirectories([testDir]);
17+
5818
await fs.mkdir(path.join(testDir, 'src'));
5919
await fs.mkdir(path.join(testDir, 'node_modules'));
6020
await fs.mkdir(path.join(testDir, '.git'));
6121
await fs.mkdir(path.join(testDir, 'nested', 'node_modules'), { recursive: true });
62-
63-
// Create test files
22+
6423
await fs.writeFile(path.join(testDir, '.env'), 'SECRET=value');
6524
await fs.writeFile(path.join(testDir, '.env.local'), 'LOCAL_SECRET=value');
6625
await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("hello");');
@@ -71,62 +30,60 @@ describe('buildTree exclude patterns', () => {
7130

7231
afterEach(async () => {
7332
await fs.rm(testDir, { recursive: true, force: true });
33+
setAllowedDirectories([]);
7434
});
7535

7636
it('should exclude files matching simple patterns', async () => {
77-
// Test the current implementation - this will fail until the bug is fixed
78-
const tree = await buildTreeForTesting(testDir, testDir, ['.env']);
37+
const tree = await buildDirectoryTree(testDir, ['.env']);
7938
const fileNames = tree.map(entry => entry.name);
80-
39+
8140
expect(fileNames).not.toContain('.env');
82-
expect(fileNames).toContain('.env.local'); // Should not exclude this
41+
expect(fileNames).toContain('.env.local');
8342
expect(fileNames).toContain('src');
8443
expect(fileNames).toContain('package.json');
8544
});
8645

8746
it('should exclude directories matching simple patterns', async () => {
88-
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
47+
const tree = await buildDirectoryTree(testDir, ['node_modules']);
8948
const dirNames = tree.map(entry => entry.name);
90-
49+
9150
expect(dirNames).not.toContain('node_modules');
9251
expect(dirNames).toContain('src');
9352
expect(dirNames).toContain('.git');
9453
});
9554

9655
it('should exclude nested directories with same pattern', async () => {
97-
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules']);
98-
99-
// Find the nested directory
56+
const tree = await buildDirectoryTree(testDir, ['node_modules']);
57+
10058
const nestedDir = tree.find(entry => entry.name === 'nested');
10159
expect(nestedDir).toBeDefined();
10260
expect(nestedDir!.children).toBeDefined();
103-
104-
// The nested/node_modules should also be excluded
61+
10562
const nestedChildren = nestedDir!.children!.map(child => child.name);
10663
expect(nestedChildren).not.toContain('node_modules');
10764
});
10865

10966
it('should handle glob patterns correctly', async () => {
110-
const tree = await buildTreeForTesting(testDir, testDir, ['*.env']);
67+
const tree = await buildDirectoryTree(testDir, ['*.env']);
11168
const fileNames = tree.map(entry => entry.name);
112-
69+
11370
expect(fileNames).not.toContain('.env');
114-
expect(fileNames).toContain('.env.local'); // *.env should not match .env.local
71+
expect(fileNames).toContain('.env.local');
11572
expect(fileNames).toContain('src');
11673
});
11774

11875
it('should handle dot files correctly', async () => {
119-
const tree = await buildTreeForTesting(testDir, testDir, ['.git']);
76+
const tree = await buildDirectoryTree(testDir, ['.git']);
12077
const dirNames = tree.map(entry => entry.name);
121-
78+
12279
expect(dirNames).not.toContain('.git');
123-
expect(dirNames).toContain('.env'); // Should not exclude this
80+
expect(dirNames).toContain('.env');
12481
});
12582

12683
it('should work with multiple exclude patterns', async () => {
127-
const tree = await buildTreeForTesting(testDir, testDir, ['node_modules', '.env', '.git']);
84+
const tree = await buildDirectoryTree(testDir, ['node_modules', '.env', '.git']);
12885
const entryNames = tree.map(entry => entry.name);
129-
86+
13087
expect(entryNames).not.toContain('node_modules');
13188
expect(entryNames).not.toContain('.env');
13289
expect(entryNames).not.toContain('.git');
@@ -135,13 +92,12 @@ describe('buildTree exclude patterns', () => {
13592
});
13693

13794
it('should handle empty exclude patterns', async () => {
138-
const tree = await buildTreeForTesting(testDir, testDir, []);
95+
const tree = await buildDirectoryTree(testDir, []);
13996
const entryNames = tree.map(entry => entry.name);
140-
141-
// All entries should be included
97+
14298
expect(entryNames).toContain('node_modules');
14399
expect(entryNames).toContain('.env');
144100
expect(entryNames).toContain('.git');
145101
expect(entryNames).toContain('src');
146102
});
147-
});
103+
});

src/filesystem/index.ts

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import fs from "fs/promises";
1111
import { createReadStream } from "fs";
1212
import path from "path";
1313
import { z } from "zod";
14-
import { minimatch } from "minimatch";
1514
import { normalizePath, expandHome } from './path-utils.js';
1615
import { getValidRootDirectories } from './roots-utils.js';
1716
import {
@@ -26,6 +25,7 @@ import {
2625
tailFile,
2726
headFile,
2827
setAllowedDirectories,
28+
buildDirectoryTree,
2929
} from './lib.js';
3030

3131
// Command line argument parsing
@@ -541,50 +541,7 @@ server.registerTool(
541541
annotations: { readOnlyHint: true }
542542
},
543543
async (args: z.infer<typeof DirectoryTreeArgsSchema>) => {
544-
interface TreeEntry {
545-
name: string;
546-
type: 'file' | 'directory';
547-
children?: TreeEntry[];
548-
}
549-
const rootPath = args.path;
550-
551-
async function buildTree(currentPath: string, excludePatterns: string[] = []): Promise<TreeEntry[]> {
552-
const validPath = await validatePath(currentPath);
553-
const entries = await fs.readdir(validPath, { withFileTypes: true });
554-
const result: TreeEntry[] = [];
555-
556-
for (const entry of entries) {
557-
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
558-
const shouldExclude = excludePatterns.some(pattern => {
559-
if (pattern.includes('*')) {
560-
return minimatch(relativePath, pattern, { dot: true });
561-
}
562-
// For files: match exact name or as part of path
563-
// For directories: match as directory path
564-
return minimatch(relativePath, pattern, { dot: true }) ||
565-
minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
566-
minimatch(relativePath, `**/${pattern}/**`, { dot: true });
567-
});
568-
if (shouldExclude)
569-
continue;
570-
571-
const entryData: TreeEntry = {
572-
name: entry.name,
573-
type: entry.isDirectory() ? 'directory' : 'file'
574-
};
575-
576-
if (entry.isDirectory()) {
577-
const subPath = path.join(currentPath, entry.name);
578-
entryData.children = await buildTree(subPath, excludePatterns);
579-
}
580-
581-
result.push(entryData);
582-
}
583-
584-
return result;
585-
}
586-
587-
const treeData = await buildTree(rootPath, args.excludePatterns);
544+
const treeData = await buildDirectoryTree(args.path, args.excludePatterns);
588545
const text = JSON.stringify(treeData, null, 2);
589546
const contentBlock = { type: "text" as const, text };
590547
return {

src/filesystem/lib.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,50 @@ export async function searchFilesWithValidation(
413413
await search(rootPath);
414414
return results;
415415
}
416+
417+
interface DirectoryTreeEntry {
418+
name: string;
419+
type: 'file' | 'directory';
420+
children?: DirectoryTreeEntry[];
421+
}
422+
423+
export async function buildDirectoryTree(
424+
rootPath: string,
425+
excludePatterns: string[] = []
426+
): Promise<DirectoryTreeEntry[]> {
427+
async function walk(currentPath: string): Promise<DirectoryTreeEntry[]> {
428+
const validPath = await validatePath(currentPath);
429+
const entries = await fs.readdir(validPath, { withFileTypes: true });
430+
const result: DirectoryTreeEntry[] = [];
431+
432+
for (const entry of entries) {
433+
const relativePath = path.relative(rootPath, path.join(currentPath, entry.name));
434+
const shouldExclude = excludePatterns.some(pattern => {
435+
if (pattern.includes('*')) {
436+
return minimatch(relativePath, pattern, { dot: true });
437+
}
438+
// For files: match exact name or as part of path
439+
// For directories: match as directory path
440+
return minimatch(relativePath, pattern, { dot: true }) ||
441+
minimatch(relativePath, `**/${pattern}`, { dot: true }) ||
442+
minimatch(relativePath, `**/${pattern}/**`, { dot: true });
443+
});
444+
if (shouldExclude) continue;
445+
446+
const entryData: DirectoryTreeEntry = {
447+
name: entry.name,
448+
type: entry.isDirectory() ? 'directory' : 'file',
449+
};
450+
451+
if (entry.isDirectory()) {
452+
entryData.children = await walk(path.join(currentPath, entry.name));
453+
}
454+
455+
result.push(entryData);
456+
}
457+
458+
return result;
459+
}
460+
461+
return walk(rootPath);
462+
}

0 commit comments

Comments
 (0)