Skip to content

Commit 65e358d

Browse files
refactor(tools): modularize filesystem
Replaced the single `src/utils/tools/filesystem.ts` module with `src/utils/tools/filesystem/index.ts`: - `files.ts` / `files.test.ts`: `readFile`, `writeFile`, `editFile` - `diff.ts`: unified diff/truncation helpers for edits - `paths.ts` / `paths.test.ts`: create/rename/delete/list path operations - `find.ts` / `find.test.ts`: recursive file discovery and ignored-dir matching - `grep.ts` / `grep.test.ts`: grep search, rg fallback, search pattern expansion - `index.ts`: barrel with the same public exports as before Callers still import from `./filesystem`, so the dispatcher surface did not change.
1 parent 188114a commit 65e358d

12 files changed

Lines changed: 1859 additions & 1827 deletions

File tree

src/utils/tools/filesystem.test.ts

Lines changed: 0 additions & 1197 deletions
This file was deleted.

src/utils/tools/filesystem.ts

Lines changed: 0 additions & 630 deletions
This file was deleted.

src/utils/tools/filesystem/diff.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
const DIFF_CONTEXT_LINES = 3;
2+
const DIFF_MAX_LINES = 120;
3+
const DIFF_MAX_CHARS = 12_000;
4+
5+
function splitLines(content: string): string[] {
6+
return content.split('\n');
7+
}
8+
9+
export function createUnifiedDiff(
10+
filePath: string,
11+
beforeContent: string,
12+
afterContent: string,
13+
): string {
14+
const beforeLines = splitLines(beforeContent);
15+
const afterLines = splitLines(afterContent);
16+
17+
let commonPrefix = 0;
18+
while (
19+
commonPrefix < beforeLines.length &&
20+
commonPrefix < afterLines.length &&
21+
beforeLines[commonPrefix] === afterLines[commonPrefix]
22+
) {
23+
commonPrefix += 1;
24+
}
25+
26+
let commonSuffix = 0;
27+
while (
28+
commonSuffix < beforeLines.length - commonPrefix &&
29+
commonSuffix < afterLines.length - commonPrefix &&
30+
beforeLines[beforeLines.length - 1 - commonSuffix] ===
31+
afterLines[afterLines.length - 1 - commonSuffix]
32+
) {
33+
commonSuffix += 1;
34+
}
35+
36+
const beforeChangeEnd = beforeLines.length - commonSuffix;
37+
const afterChangeEnd = afterLines.length - commonSuffix;
38+
const hunkStart = Math.max(0, commonPrefix - DIFF_CONTEXT_LINES);
39+
const beforeHunkEnd = Math.min(
40+
beforeLines.length,
41+
beforeChangeEnd + DIFF_CONTEXT_LINES,
42+
);
43+
const afterHunkEnd = Math.min(
44+
afterLines.length,
45+
afterChangeEnd + DIFF_CONTEXT_LINES,
46+
);
47+
48+
const beforeHunkLines = beforeLines.slice(hunkStart, beforeHunkEnd);
49+
const afterHunkLines = afterLines.slice(hunkStart, afterHunkEnd);
50+
const beforeChangedLines = beforeLines.slice(commonPrefix, beforeChangeEnd);
51+
const afterChangedLines = afterLines.slice(commonPrefix, afterChangeEnd);
52+
const contextBefore = beforeLines.slice(hunkStart, commonPrefix);
53+
const contextAfter = beforeLines.slice(beforeChangeEnd, beforeHunkEnd);
54+
55+
const lines = [
56+
`--- ${filePath}`,
57+
`+++ ${filePath}`,
58+
`@@ -${String(hunkStart + 1)},${String(beforeHunkLines.length)} +${String(hunkStart + 1)},${String(afterHunkLines.length)} @@`,
59+
...contextBefore.map((line) => ` ${line}`),
60+
...beforeChangedLines.map((line) => `-${line}`),
61+
...afterChangedLines.map((line) => `+${line}`),
62+
...contextAfter.map((line) => ` ${line}`),
63+
];
64+
65+
return lines.join('\n');
66+
}
67+
68+
export function truncateDiff(diff: string): {
69+
visible: string;
70+
truncated: boolean;
71+
totalLines: number;
72+
visibleLines: number;
73+
} {
74+
const lines = diff.split('\n');
75+
let visibleLines = lines.slice(0, DIFF_MAX_LINES);
76+
let truncated = lines.length > DIFF_MAX_LINES;
77+
78+
while (visibleLines.join('\n').length > DIFF_MAX_CHARS) {
79+
visibleLines = visibleLines.slice(0, -1);
80+
truncated = true;
81+
}
82+
83+
if (truncated) {
84+
visibleLines = [
85+
...visibleLines,
86+
`[diff truncated: showing ${String(visibleLines.length)} of ${String(lines.length)} lines]`,
87+
];
88+
}
89+
90+
return {
91+
visible: visibleLines.join('\n'),
92+
truncated,
93+
totalLines: lines.length,
94+
visibleLines: Math.min(visibleLines.length, lines.length),
95+
};
96+
}
Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2+
3+
import { editFile, readFile, writeFile } from '.';
4+
5+
vi.mock('node:fs');
6+
7+
describe('files', () => {
8+
beforeEach(() => {
9+
vi.resetAllMocks();
10+
});
11+
12+
describe('readFile', () => {
13+
it('reads file contents', () => {
14+
vi.mocked(existsSync).mockReturnValue(true);
15+
vi.mocked(readFileSync).mockReturnValue('file content');
16+
17+
const result = readFile('/test.txt');
18+
expect(result.content).toBe('file content');
19+
expect(result.error).toBeUndefined();
20+
});
21+
22+
it('returns error when file does not exist', () => {
23+
vi.mocked(existsSync).mockReturnValue(false);
24+
25+
const result = readFile('/missing.txt');
26+
expect(result.error).toContain('File not found');
27+
});
28+
29+
it('reads a specific line range with line numbers', () => {
30+
vi.mocked(existsSync).mockReturnValue(true);
31+
vi.mocked(readFileSync).mockReturnValue(
32+
'line1\nline2\nline3\nline4\nline5',
33+
);
34+
35+
const result = readFile('/test.txt', { endLine: 4, startLine: 2 });
36+
37+
expect(result.content).toBe('2: line2\n3: line3\n4: line4');
38+
expect(result.error).toBeUndefined();
39+
});
40+
41+
it('reads maxLines from the start of a file with line numbers', () => {
42+
vi.mocked(existsSync).mockReturnValue(true);
43+
vi.mocked(readFileSync).mockReturnValue('line1\nline2\nline3');
44+
45+
const result = readFile('/test.txt', { maxLines: 2 });
46+
47+
expect(result.content).toBe('1: line1\n2: line2');
48+
});
49+
50+
it('reads maxLines from a startLine with line numbers', () => {
51+
vi.mocked(existsSync).mockReturnValue(true);
52+
vi.mocked(readFileSync).mockReturnValue('line1\nline2\nline3\nline4');
53+
54+
const result = readFile('/test.txt', { maxLines: 2, startLine: 3 });
55+
56+
expect(result.content).toBe('3: line3\n4: line4');
57+
});
58+
59+
it('reads from startLine through the end of the file', () => {
60+
vi.mocked(existsSync).mockReturnValue(true);
61+
vi.mocked(readFileSync).mockReturnValue('line1\nline2\nline3');
62+
63+
const result = readFile('/test.txt', { startLine: 2 });
64+
65+
expect(result.content).toBe('2: line2\n3: line3');
66+
});
67+
68+
it('clamps ranged reads beyond file length to the last line', () => {
69+
vi.mocked(existsSync).mockReturnValue(true);
70+
vi.mocked(readFileSync).mockReturnValue('line1\nline2\nline3');
71+
72+
const result = readFile('/test.txt', { endLine: 999, startLine: 2 });
73+
74+
expect(result.content).toBe('2: line2\n3: line3');
75+
});
76+
77+
it('returns error for invalid line range when start exceeds file length', () => {
78+
vi.mocked(existsSync).mockReturnValue(true);
79+
vi.mocked(readFileSync).mockReturnValue('short file');
80+
81+
const result = readFile('/test.txt', { endLine: 200, startLine: 100 });
82+
83+
expect(result.error).toContain('Invalid line range');
84+
});
85+
86+
it('returns error when read fails', () => {
87+
vi.mocked(existsSync).mockReturnValue(true);
88+
vi.mocked(readFileSync).mockImplementation(() => {
89+
throw new Error('Permission denied');
90+
});
91+
92+
const result = readFile('/test.txt');
93+
expect(result.error).toContain('Failed to read file');
94+
});
95+
96+
it('handles non-Error exceptions', () => {
97+
vi.mocked(existsSync).mockReturnValue(true);
98+
vi.mocked(readFileSync).mockImplementation(() => {
99+
// eslint-disable-next-line @typescript-eslint/only-throw-error
100+
throw 'string error';
101+
});
102+
103+
const result = readFile('/test.txt');
104+
expect(result.error).toContain('Failed to read file');
105+
expect(result.error).toContain('string error');
106+
});
107+
});
108+
109+
describe('writeFile', () => {
110+
it('writes file successfully', () => {
111+
const result = writeFile('/test.txt', 'new content');
112+
expect(result.content).toContain('File written successfully');
113+
expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith(
114+
'/test.txt',
115+
'new content',
116+
'utf8',
117+
);
118+
});
119+
120+
it('returns error when write fails', () => {
121+
vi.mocked(writeFileSync).mockImplementation(() => {
122+
throw new Error('Disk full');
123+
});
124+
125+
const result = writeFile('/test.txt', 'data');
126+
expect(result.error).toContain('Failed to write file');
127+
});
128+
129+
it('handles non-Error exceptions', () => {
130+
vi.mocked(writeFileSync).mockImplementation(() => {
131+
// eslint-disable-next-line @typescript-eslint/only-throw-error
132+
throw 'disk full';
133+
});
134+
135+
const result = writeFile('/test.txt', 'data');
136+
expect(result.error).toContain('Failed to write file');
137+
expect(result.error).toContain('disk full');
138+
});
139+
});
140+
141+
describe('editFile', () => {
142+
it('edits file successfully', () => {
143+
vi.mocked(existsSync).mockReturnValue(true);
144+
vi.mocked(readFileSync).mockReturnValue('before target after');
145+
146+
const result = editFile('/test.txt', 'target', 'updated');
147+
expect(result.content).toContain('File edited successfully');
148+
expect(vi.mocked(writeFileSync)).toHaveBeenCalledWith(
149+
'/test.txt',
150+
'before updated after',
151+
'utf8',
152+
);
153+
});
154+
155+
it('returns error when file does not exist', () => {
156+
vi.mocked(readFileSync).mockImplementation(() => {
157+
const error = new Error('ENOENT');
158+
(error as { code?: string }).code = 'ENOENT';
159+
throw error;
160+
});
161+
162+
const result = editFile('/missing.txt', 'before', 'after');
163+
expect(result.error).toContain('File not found');
164+
});
165+
166+
it('returns error when text is not found', () => {
167+
vi.mocked(existsSync).mockReturnValue(true);
168+
vi.mocked(readFileSync).mockReturnValue('content');
169+
170+
const result = editFile('/test.txt', 'missing', 'after');
171+
expect(result.error).toContain('Exact text not found');
172+
});
173+
174+
it('returns error when text matches multiple locations', () => {
175+
vi.mocked(existsSync).mockReturnValue(true);
176+
vi.mocked(readFileSync).mockReturnValue('repeat repeat');
177+
178+
const result = editFile('/test.txt', 'repeat', 'after');
179+
expect(result.error).toContain('matched multiple locations');
180+
});
181+
182+
it('truncates diff when it exceeds DIFF_MAX_CHARS even within line limit', () => {
183+
const longLine = 'x'.repeat(13_000);
184+
const before = 'original';
185+
186+
vi.mocked(existsSync).mockReturnValue(true);
187+
vi.mocked(readFileSync).mockReturnValue(before);
188+
vi.mocked(writeFileSync).mockImplementation(() => undefined);
189+
190+
const result = editFile('/test.txt', 'original', longLine);
191+
expect(result.content).toContain('File edited successfully');
192+
expect(result.diff).toBeDefined();
193+
expect(result.diff?.truncated).toBe(true);
194+
expect(result.diff?.visible).toContain('[diff truncated:');
195+
});
196+
197+
it('produces diff with context lines when change is in the middle of a file', () => {
198+
const before = [
199+
'line one',
200+
'line two',
201+
'line three',
202+
'target line',
203+
'line five',
204+
'line six',
205+
'line seven',
206+
].join('\n');
207+
208+
vi.mocked(existsSync).mockReturnValue(true);
209+
vi.mocked(readFileSync).mockReturnValue(before);
210+
vi.mocked(writeFileSync).mockImplementation(() => undefined);
211+
212+
const result = editFile('/test.txt', 'target line', 'replaced line');
213+
expect(result.content).toContain('File edited successfully');
214+
expect(result.diff).toBeDefined();
215+
expect(result.diff?.visible).toContain('-target line');
216+
expect(result.diff?.visible).toContain('+replaced line');
217+
expect(result.diff?.visible).toContain(' line two');
218+
expect(result.diff?.visible).toContain(' line six');
219+
});
220+
221+
it('truncates diff when it exceeds DIFF_MAX_LINES', () => {
222+
const removedBlock = Array.from(
223+
{ length: 130 },
224+
(_, i) => `removed ${String(i)}`,
225+
).join('\n');
226+
const before = `header\n${removedBlock}\nfooter`;
227+
228+
vi.mocked(existsSync).mockReturnValue(true);
229+
vi.mocked(readFileSync).mockReturnValue(before);
230+
vi.mocked(writeFileSync).mockImplementation(() => undefined);
231+
232+
const result = editFile('/test.txt', removedBlock, 'replacement');
233+
expect(result.diff?.truncated).toBe(true);
234+
expect(result.diff?.visible).toContain('[diff truncated:');
235+
});
236+
237+
it('returns error when read fails', () => {
238+
vi.mocked(readFileSync).mockImplementation(() => {
239+
throw new Error('Permission denied');
240+
});
241+
242+
const result = editFile('/test.txt', 'before', 'after');
243+
expect(result.error).toContain('File not found');
244+
});
245+
246+
it('returns error when write fails', () => {
247+
vi.mocked(readFileSync).mockReturnValue('before');
248+
vi.mocked(writeFileSync).mockImplementation(() => {
249+
throw new Error('Disk full');
250+
});
251+
252+
const result = editFile('/test.txt', 'before', 'after');
253+
expect(result.error).toContain('Failed to edit file');
254+
});
255+
256+
it('returns error when write fails with non-Error', () => {
257+
vi.mocked(readFileSync).mockReturnValue('before');
258+
vi.mocked(writeFileSync).mockImplementation(() => {
259+
// eslint-disable-next-line @typescript-eslint/only-throw-error
260+
throw 'disk full';
261+
});
262+
263+
const result = editFile('/test.txt', 'before', 'after');
264+
expect(result.error).toContain('Failed to edit file');
265+
expect(result.error).toContain('disk full');
266+
});
267+
268+
it('handles non-Error exceptions', () => {
269+
vi.mocked(readFileSync).mockImplementation(() => {
270+
// eslint-disable-next-line @typescript-eslint/only-throw-error
271+
throw 'read failed';
272+
});
273+
274+
const result = editFile('/test.txt', 'before', 'after');
275+
expect(result.error).toContain('File not found');
276+
});
277+
});
278+
});

0 commit comments

Comments
 (0)